diff --git a/android/README.md b/android/README.md new file mode 100644 index 00000000..13a641cf --- /dev/null +++ b/android/README.md @@ -0,0 +1,68 @@ +# 울반 - NFC기반 학급 소통 앱 +![](https://blog.kakaocdn.net/dn/kTWTG/btsHtPbRbMO/ApDuCfEaKyqA57ZlnGnkU0/img.png) + +
+

📱 NFC, 블루투스 기반으로 소통해요

+ +

🎯 친구들과 채팅하고 글을 쓰며 일상을 공유해요

+ +

👥 친구들과의 친밀도를 확인해요

+ +

🥇 선생님은 학생들의 소통 통계를 확인해요

+

+
+ +## 개요 + +- **한 줄 요약** : *울반* 프로젝트는 NFC와 BLE 통신을 기반으로 한 학급 소통 앱입니다. + +- **기획의도** : 코로나 이후 악화된 교우관개를 개선을 위해 제작되었습니다. + +- **개발 인원 및 기간** + + - **개발 인원** : Android 3명, BackEnd 3명 + + - **프로젝트 기간** : 2024.04.08 ~ 2024.05.19 + +- **주요 기능** + + - NFC 기반의 태깅인사, 이어달리기 + + - 블루투스 기반의 함께달리기 + + - 학급별 채팅, 게시판, 알림장 + + - 학급별 소통 통계 + +



+ + +# 프로젝트 구조 +![](https://blog.kakaocdn.net/dn/17Gn2/btsHtQhx6Ti/nfrxsULAZmdlsdLl3SJZxK/img.png) + +### 기술 +- Android: Hilt, Jetpack AAC(ViewModel, Room), Jetpack Compose, Paging +- Kotlin : Coroutine, Flow, KotlinSerialization +- Library : Retrofit, Coil, FCM, KakaoSocialAuth, KrossBow(Stomp) +- Architecture : MVI, MultiModule, CleanArchitecture +- Connection : NFC, BlueTooth +



+ + +# 동작 화면 + +**주요 동작화면은 추후 추가 예정입니다.** + +### [피그마](https://www.figma.com/design/yfm5gTmRJED2uAdm7H70YC/6-kids-on-the-block?node-id=0%3A1&t=5blyLSniokJVPpQR-1) + + +


+## 개발 환경 + +- Android Studio : Iguana | 2023.2.1 Patch 2 +- Gradle JDK : jbr-17(JetBrains Runtime version 17.0.6) +- Android Gradle Plugin Version : 8.1.3 +- Gradle Version : 8.1 +- Kotlin version : 1.8.0 + +## 역할 diff --git a/android/app/.gitignore b/android/app/.gitignore index 42afabfd..2abde4aa 100644 --- a/android/app/.gitignore +++ b/android/app/.gitignore @@ -1 +1,2 @@ -/build \ No newline at end of file +/build +/google-services.json diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 44939ca6..d55b696a 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,16 +1,41 @@ +import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties +import java.util.Properties + plugins { alias(libs.plugins.sixkids.android.application) } +fun getProperty(propertyKey: String): String = + gradleLocalProperties(rootDir, providers).getProperty(propertyKey) + android { namespace = "com.sixkids.ulban" defaultConfig { applicationId = "com.sixkids.ulban" versionCode = 1 versionName = "1.0" + + val localProperties = Properties() + val localPropertiesFile = rootProject.file("local.properties") + if (localPropertiesFile.exists()) { + localProperties.load(localPropertiesFile.inputStream()) + } + + val nativeAppKey = localProperties.getProperty("KAKAO_NATIVE_APP_KEY") ?: "" + manifestPlaceholders["NATIVE_APP_KEY"] = nativeAppKey + + buildConfigField("String", "KAKAO_NATIVE_APP_KEY", "\"${nativeAppKey}\"") + } + + buildFeatures { + buildConfig = true } } dependencies { implementation(projects.feature.navigator) + implementation(projects.data) + implementation(libs.kakao.user) + implementation(platform(libs.firebase.bom)) + implementation(libs.bundles.firebase) } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 06c5d6a2..dc623bcd 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,15 +2,41 @@ + + + tools:targetApi="31"> + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/ic_app_icon-playstore.png b/android/app/src/main/ic_app_icon-playstore.png new file mode 100644 index 00000000..22754ad4 Binary files /dev/null and b/android/app/src/main/ic_app_icon-playstore.png differ diff --git a/android/app/src/main/java/com/sixkids/ulban/UlbanApplication.kt b/android/app/src/main/java/com/sixkids/ulban/UlbanApplication.kt index bae5c678..510dbe6d 100644 --- a/android/app/src/main/java/com/sixkids/ulban/UlbanApplication.kt +++ b/android/app/src/main/java/com/sixkids/ulban/UlbanApplication.kt @@ -1,8 +1,28 @@ package com.sixkids.ulban import android.app.Application +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import com.kakao.sdk.common.KakaoSdk +import com.sixkids.ulban.UlbanFirebaseMessagingService.Companion.CHANNEL_ID import dagger.hilt.android.HiltAndroidApp @HiltAndroidApp -class UlbanApplication: Application(){ +class UlbanApplication : Application() { + override fun onCreate() { + super.onCreate() + createNotificationChannel() + + KakaoSdk.init(this, BuildConfig.KAKAO_NATIVE_APP_KEY) + } + + private fun createNotificationChannel() { + val importance = NotificationManager.IMPORTANCE_DEFAULT + val channel = NotificationChannel(CHANNEL_ID, "Ulban", importance) + + val notificationManager: NotificationManager = + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) + } } diff --git a/android/app/src/main/java/com/sixkids/ulban/UlbanFirebaseMessagingService.kt b/android/app/src/main/java/com/sixkids/ulban/UlbanFirebaseMessagingService.kt new file mode 100644 index 00000000..326454a0 --- /dev/null +++ b/android/app/src/main/java/com/sixkids/ulban/UlbanFirebaseMessagingService.kt @@ -0,0 +1,83 @@ +package com.sixkids.ulban + +import android.Manifest +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.util.Log +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationCompat +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import com.sixkids.designsystem.R +import com.sixkids.feature.navigator.MainActivity + +private const val TAG = "D107" +class UlbanFirebaseMessagingService : FirebaseMessagingService() { + + override fun onNewToken(token: String) { + super.onNewToken(token) + Log.d(TAG, "onNewToken: $token") + } + override fun onMessageReceived(message: RemoteMessage) { + var messageTitle = "" + var messageContent = "" + + message.notification?.let { + messageTitle = it.title.toString() + messageContent = it.body.toString() + } ?: run { + message.data.isNotEmpty().let { + messageTitle = message.data["title"].toString() + messageContent = message.data["body"].toString() + } + } + + + val mainIntent = Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + + val pendingIntentFlags = + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + + val mainPendingIntent: PendingIntent = PendingIntent.getActivity( + this, + 0, + mainIntent, + pendingIntentFlags, + ) + + val builder = NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.announce) + .setContentTitle(messageTitle) + .setContentText(messageContent) + .setAutoCancel(true) + .setContentIntent(mainPendingIntent) + + val notificationManager = + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (ActivityCompat.checkSelfPermission( + this, + Manifest.permission.POST_NOTIFICATIONS, + ) != PackageManager.PERMISSION_GRANTED + ) { + return + } + } + + notificationManager.notify(107, builder.build()) + + } + + companion object { + const val CHANNEL_ID = "ULBAN_NOTIFICATION_CHANNEL" + } + + +} diff --git a/android/app/src/main/res/drawable/ic_app_icon_background.xml b/android/app/src/main/res/drawable/ic_app_icon_background.xml new file mode 100644 index 00000000..ca3826a4 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_app_icon_background.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_app_icon.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_app_icon.xml new file mode 100644 index 00000000..6cb34a23 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_app_icon.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_app_icon_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_app_icon_round.xml new file mode 100644 index 00000000..6cb34a23 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_app_icon_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-hdpi/ic_app_icon.webp b/android/app/src/main/res/mipmap-hdpi/ic_app_icon.webp new file mode 100644 index 00000000..21939df9 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_app_icon.webp differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_app_icon_foreground.webp b/android/app/src/main/res/mipmap-hdpi/ic_app_icon_foreground.webp new file mode 100644 index 00000000..e71d8f54 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_app_icon_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_app_icon_round.webp b/android/app/src/main/res/mipmap-hdpi/ic_app_icon_round.webp new file mode 100644 index 00000000..e72bb0b6 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_app_icon_round.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_app_icon.webp b/android/app/src/main/res/mipmap-mdpi/ic_app_icon.webp new file mode 100644 index 00000000..62454840 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_app_icon.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_app_icon_foreground.webp b/android/app/src/main/res/mipmap-mdpi/ic_app_icon_foreground.webp new file mode 100644 index 00000000..6c4afc2c Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_app_icon_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_app_icon_round.webp b/android/app/src/main/res/mipmap-mdpi/ic_app_icon_round.webp new file mode 100644 index 00000000..488e3a6a Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_app_icon_round.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_app_icon.webp b/android/app/src/main/res/mipmap-xhdpi/ic_app_icon.webp new file mode 100644 index 00000000..c565e2f2 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_app_icon.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_app_icon_foreground.webp b/android/app/src/main/res/mipmap-xhdpi/ic_app_icon_foreground.webp new file mode 100644 index 00000000..ad06040f Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_app_icon_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_app_icon_round.webp b/android/app/src/main/res/mipmap-xhdpi/ic_app_icon_round.webp new file mode 100644 index 00000000..c4fdbecc Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_app_icon_round.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_app_icon.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_app_icon.webp new file mode 100644 index 00000000..0d9f7952 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_app_icon.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_app_icon_foreground.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_app_icon_foreground.webp new file mode 100644 index 00000000..5b470a91 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_app_icon_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_app_icon_round.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_app_icon_round.webp new file mode 100644 index 00000000..bb832098 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_app_icon_round.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_app_icon.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_app_icon.webp new file mode 100644 index 00000000..64893d8b Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_app_icon.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_app_icon_foreground.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_app_icon_foreground.webp new file mode 100644 index 00000000..7cdb203c Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_app_icon_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_app_icon_round.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_app_icon_round.webp new file mode 100644 index 00000000..de1ef0b2 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_app_icon_round.webp differ diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index a0b66fce..1f6fc661 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,3 +1,3 @@ - ulban + 울반 \ No newline at end of file diff --git a/android/build-logic/convention/src/main/java/AndroidApplicationConventionPlugin.kt b/android/build-logic/convention/src/main/java/AndroidApplicationConventionPlugin.kt index c1fb9749..f651f9fa 100644 --- a/android/build-logic/convention/src/main/java/AndroidApplicationConventionPlugin.kt +++ b/android/build-logic/convention/src/main/java/AndroidApplicationConventionPlugin.kt @@ -13,6 +13,7 @@ internal class AndroidApplicationConventionPlugin : Plugin{ apply("com.android.application") apply("org.jetbrains.kotlin.android") apply("sixkids.android.hilt") + apply("com.google.gms.google-services") } extensions.configure{ diff --git a/android/build-logic/convention/src/main/java/FeatureComposeConventionPlugin.kt b/android/build-logic/convention/src/main/java/FeatureComposeConventionPlugin.kt index 440a0bab..f26d6642 100644 --- a/android/build-logic/convention/src/main/java/FeatureComposeConventionPlugin.kt +++ b/android/build-logic/convention/src/main/java/FeatureComposeConventionPlugin.kt @@ -1,3 +1,4 @@ +import com.sixkids.convention.libs import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.kotlin.dsl.dependencies @@ -17,6 +18,8 @@ class FeatureComposeConventionPlugin: Plugin { "implementation"(project(":core:ui")) "implementation"(project(":core:designsystem")) "implementation"(project(":domain")) + "implementation"(libs.findLibrary("coil-compose").get()) + } } } diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 8349eec4..750638f1 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -6,4 +6,13 @@ plugins { alias(libs.plugins.androidLibrary) apply false alias(libs.plugins.ksp) apply false alias(libs.plugins.hilt) apply false + alias(libs.plugins.googleServices) apply false } + +buildscript{ + repositories { + mavenCentral() + google() + maven ( url = "https://jitpack.io" ) + } +} \ No newline at end of file diff --git a/android/core/bluetooth/.gitignore b/android/core/bluetooth/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/android/core/bluetooth/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/android/core/bluetooth/build.gradle.kts b/android/core/bluetooth/build.gradle.kts new file mode 100644 index 00000000..656d03eb --- /dev/null +++ b/android/core/bluetooth/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + alias(libs.plugins.sixkids.android.library) + alias(libs.plugins.sixkids.android.hilt) +} + +android { + namespace = "com.sixkids.core.bluetooth" + +} + +dependencies { + implementation(libs.androidx.annotation.jvm) + implementation(libs.kotlinx.coroutines.core) + implementation(projects.core.model) +} diff --git a/android/core/bluetooth/consumer-rules.pro b/android/core/bluetooth/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/android/core/bluetooth/proguard-rules.pro b/android/core/bluetooth/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/android/core/bluetooth/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/android/core/bluetooth/src/main/AndroidManifest.xml b/android/core/bluetooth/src/main/AndroidManifest.xml new file mode 100644 index 00000000..90ceb4b6 --- /dev/null +++ b/android/core/bluetooth/src/main/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + + + + + + + + diff --git a/android/core/bluetooth/src/main/kotlin/com/sixkids/core/bluetooth/BluetoothScanner.kt b/android/core/bluetooth/src/main/kotlin/com/sixkids/core/bluetooth/BluetoothScanner.kt new file mode 100644 index 00000000..5aaade84 --- /dev/null +++ b/android/core/bluetooth/src/main/kotlin/com/sixkids/core/bluetooth/BluetoothScanner.kt @@ -0,0 +1,73 @@ +package com.sixkids.core.bluetooth + +import android.Manifest +import android.bluetooth.BluetoothManager +import android.bluetooth.le.BluetoothLeScanner +import android.bluetooth.le.ScanCallback +import android.bluetooth.le.ScanResult +import android.content.Context +import android.os.ParcelUuid +import android.util.Log +import androidx.annotation.RequiresPermission +import com.sixkids.core.bluetooth.BluetoothServer.Companion.ULBAN_UUID +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import java.nio.ByteBuffer +import java.util.UUID + +private const val TAG = "D107 ble" + +class BluetoothScanner(context: Context) { + private val bluetooth = context.getSystemService(Context.BLUETOOTH_SERVICE) + as? BluetoothManager ?: throw Exception("This device doesn't support Bluetooth") + + private val scanner: BluetoothLeScanner + get() = bluetooth.adapter.bluetoothLeScanner + + val isScanning = MutableStateFlow(false) + val foundDevices = MutableStateFlow>(emptyList()) + + private val scanCallback = object : ScanCallback() { + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + override fun onScanResult(callbackType: Int, result: ScanResult?) { + super.onScanResult(callbackType, result) + result ?: return + + val bytes = result.scanRecord?.getServiceData(ParcelUuid(UUID.fromString(ULBAN_UUID))) + + bytes?.let { + val memberId = ByteBuffer.wrap(bytes).long + if (!foundDevices.value.contains(memberId)) { + foundDevices.update { it + memberId } + } + } + } + + override fun onBatchScanResults(results: MutableList?) { + super.onBatchScanResults(results) + } + + override fun onScanFailed(errorCode: Int) { + super.onScanFailed(errorCode) + Log.d(TAG, "onScanFailed: 실패") + isScanning.value = false + } + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_SCAN) + fun startScanning() { + scanner.startScan(scanCallback) + isScanning.value = true + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_SCAN) + fun stopScanning() { + scanner.stopScan(scanCallback) + isScanning.value = false + } + + fun removeDevice(memberId: Long) { + foundDevices.update { it - memberId } + } + +} diff --git a/android/core/bluetooth/src/main/kotlin/com/sixkids/core/bluetooth/BluetoothServer.kt b/android/core/bluetooth/src/main/kotlin/com/sixkids/core/bluetooth/BluetoothServer.kt new file mode 100644 index 00000000..afa85bf3 --- /dev/null +++ b/android/core/bluetooth/src/main/kotlin/com/sixkids/core/bluetooth/BluetoothServer.kt @@ -0,0 +1,89 @@ +package com.sixkids.core.bluetooth + +import android.Manifest +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothManager +import android.bluetooth.le.AdvertiseCallback +import android.bluetooth.le.AdvertiseData +import android.bluetooth.le.AdvertiseSettings +import android.bluetooth.le.BluetoothLeAdvertiser +import android.content.Context +import android.os.Build +import android.os.ParcelUuid +import androidx.annotation.RequiresPermission +import java.nio.ByteBuffer +import java.util.UUID +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +class BluetoothServer(context: Context) { + private val bluetooth = context.getSystemService(Context.BLUETOOTH_SERVICE) + as? BluetoothManager ?: throw Exception("This device doesn't support Bluetooth") + + private val bluetoothAdapter by lazy { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) + bluetooth.adapter + else { + BluetoothAdapter.getDefaultAdapter() + } + } + + private var advertiseCallback: AdvertiseCallback? = null + + @RequiresPermission(allOf = [Manifest.permission.BLUETOOTH_ADVERTISE, Manifest.permission.BLUETOOTH_CONNECT]) + suspend fun startAdvertising(memberId: Long) { + val advertiser: BluetoothLeAdvertiser = bluetoothAdapter.bluetoothLeAdvertiser + ?: throw Exception("This device doesn't support Bluetooth advertising") + + //if already advertising, ignore + if (advertiseCallback != null) { + return + } + + val memberIdBytes = ByteBuffer.allocate(Long.SIZE_BYTES).putLong(memberId).array() + val uuid = UUID.fromString(ULBAN_UUID) // 고유 UUID + + + val settings = AdvertiseSettings.Builder() + .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY) + .setConnectable(true) + .setTimeout(0) + .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_MEDIUM) + .build() + + val data = AdvertiseData.Builder() + .setIncludeDeviceName(false) + .setIncludeTxPowerLevel(false) + .addServiceData(ParcelUuid(uuid), memberIdBytes) + .build() + + advertiseCallback = suspendCoroutine { continuation -> + val advertiseCallback = object : AdvertiseCallback() { + override fun onStartSuccess(settingsInEffect: AdvertiseSettings?) { + continuation.resume(this) + } + + override fun onStartFailure(errorCode: Int) { + super.onStartFailure(errorCode) + throw Exception("Unable to start advertising, errorCode: $errorCode") + } + } + advertiser.startAdvertising(settings, data, advertiseCallback) + } + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_ADVERTISE) + fun stopAdvertising() { + val advertiser: BluetoothLeAdvertiser = bluetoothAdapter.bluetoothLeAdvertiser + ?: throw Exception("This device doesn't support Bluetooth advertising") + + advertiseCallback?.let { + advertiser.stopAdvertising(it) + advertiseCallback = null + } + } + + companion object { + const val ULBAN_UUID = "0461c2e0-7d45-437f-929b-72aa4b355a96" + } +} diff --git a/android/core/bluetooth/src/main/kotlin/com/sixkids/core/bluetooth/di/BluetoothModule.kt b/android/core/bluetooth/src/main/kotlin/com/sixkids/core/bluetooth/di/BluetoothModule.kt new file mode 100644 index 00000000..62fc7936 --- /dev/null +++ b/android/core/bluetooth/src/main/kotlin/com/sixkids/core/bluetooth/di/BluetoothModule.kt @@ -0,0 +1,33 @@ +package com.sixkids.core.bluetooth.di + +import android.content.Context +import com.sixkids.core.bluetooth.BluetoothScanner +import com.sixkids.core.bluetooth.BluetoothServer +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object BluetoothModule { + + @Provides + @Singleton + fun provideBluetoothScanner( + @ApplicationContext context: Context + ): BluetoothScanner { + return BluetoothScanner(context) + } + + @Provides + @Singleton + fun provideBluetoothServer( + @ApplicationContext context: Context + ): BluetoothServer { + return BluetoothServer(context) + } + +} diff --git a/android/core/designsystem/build.gradle.kts b/android/core/designsystem/build.gradle.kts index 9847c821..13841a5a 100644 --- a/android/core/designsystem/build.gradle.kts +++ b/android/core/designsystem/build.gradle.kts @@ -10,4 +10,6 @@ android { dependencies { implementation(projects.core.ui) implementation (libs.accompanist.systemuicontroller) + implementation(libs.coil.compose) + implementation(libs.lottie) } diff --git a/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/appbar/AppBarScreenPreview.kt b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/appbar/AppBarScreenPreview.kt index 5a856110..51c36a2d 100644 --- a/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/appbar/AppBarScreenPreview.kt +++ b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/appbar/AppBarScreenPreview.kt @@ -73,6 +73,7 @@ fun RelayDetailAppBarPreview() { topDescription = "04.17 15:00~", bottomDescription = "현재 주자는 오하빈 학생입니다.", color = Orange, + badgeCount = 10, onclick = { Log.d("확인", "클릭된 ") }, expanded = !isScrolled ) diff --git a/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/appbar/BasicAppBar.kt b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/appbar/BasicAppBar.kt index 89fef034..ae06f149 100644 --- a/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/appbar/BasicAppBar.kt +++ b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/appbar/BasicAppBar.kt @@ -51,7 +51,7 @@ fun BasicAppBar( ) val animatedHeight by animateDpAsState( - targetValue = if (expanded) 180.dp else 60.dp, + targetValue = if (expanded) 210.dp else 60.dp, animationSpec = TweenSpec(durationMillis = 300), label = "앱바 크기" ) @@ -88,7 +88,7 @@ fun BasicAppBar( Icon( modifier = Modifier .fillMaxHeight() - .padding(vertical = 16.dp) + .padding(vertical = 20.dp) .aspectRatio(1f), painter = painterResource(id = leftIcon), contentDescription = "로고", diff --git a/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/appbar/UlbanDefaultAppBar.kt b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/appbar/UlbanDefaultAppBar.kt index 4743b730..51f27c03 100644 --- a/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/appbar/UlbanDefaultAppBar.kt +++ b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/appbar/UlbanDefaultAppBar.kt @@ -1,14 +1,18 @@ package com.sixkids.designsystem.component.appbar import androidx.annotation.DrawableRes +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp import com.sixkids.designsystem.theme.AppBarTypography +import com.sixkids.designsystem.theme.UlbanTypography @Composable fun UlbanDefaultAppBar( @@ -16,9 +20,10 @@ fun UlbanDefaultAppBar( @DrawableRes leftIcon: Int, title: String, content: String, + body: String = "", color: Color, expanded: Boolean = true, - onclick: () -> Unit, + onclick: () -> Unit = {}, ) { BasicAppBar( modifier = modifier, @@ -27,9 +32,18 @@ fun UlbanDefaultAppBar( content = { Row(modifier = Modifier.fillMaxWidth()) { Spacer(modifier = Modifier.weight(1f)) - Text( - text = content, style = AppBarTypography.titleLarge, - ) + Column { + Text( + text = content, style = UlbanTypography.titleLarge, + ) + if (body.isNotEmpty()) { + Text( + text = body, style = UlbanTypography.bodyLarge, + modifier = Modifier.padding(top = 4.dp) + ) + } + } + Spacer(modifier = Modifier.weight(3f)) } }, diff --git a/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/appbar/UlbanDetailAppBar.kt b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/appbar/UlbanDetailAppBar.kt index 5bd6fc1e..9646a35f 100644 --- a/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/appbar/UlbanDetailAppBar.kt +++ b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/appbar/UlbanDetailAppBar.kt @@ -3,17 +3,26 @@ package com.sixkids.designsystem.component.appbar import androidx.annotation.DrawableRes import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row 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.layout.size +import androidx.compose.material3.Badge +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.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.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.sixkids.designsystem.theme.AppBarTypography +import com.sixkids.designsystem.theme.RedDark import com.sixkids.designsystem.theme.UlbanTheme +import com.sixkids.designsystem.theme.UlbanTypography @Composable fun UlbanDetailAppBar( @@ -23,9 +32,10 @@ fun UlbanDetailAppBar( content: String, topDescription: String, bottomDescription: String, + badgeCount: Int = 0, color: Color, expanded: Boolean = true, - onclick: () -> Unit, + onclick: () -> Unit = {}, ) { BasicAppBar( modifier = modifier, @@ -35,7 +45,8 @@ fun UlbanDetailAppBar( AppBarDetailInfo( title = content, topDescription = topDescription, - bottomDescription = bottomDescription + bottomDescription = bottomDescription, + badgeCount = badgeCount ) }, color = color, @@ -44,31 +55,54 @@ fun UlbanDetailAppBar( ) } +@OptIn(ExperimentalMaterial3Api::class) @Composable fun AppBarDetailInfo( modifier: Modifier = Modifier, title: String, topDescription: String, bottomDescription: String, + badgeCount: Int = 0 ) { Column( modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.Center ) { - Text( - text = topDescription, - style = AppBarTypography.bodySmall, - modifier = modifier.fillMaxWidth() - ) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Bottom + ) { + Text( + text = topDescription, + style = AppBarTypography.bodySmall, + ) + Spacer(modifier = modifier.weight(1f)) + if (badgeCount > 0) { + Badge( + modifier = Modifier + .padding(top = 4.dp, end = 12.dp) + .size(32.dp), + containerColor = RedDark, + contentColor = Color.White + ) { + Text( + text = "$badgeCount", + style = AppBarTypography.bodyLarge.copy(fontWeight = FontWeight.Bold) + ) + } + } + } + Spacer(modifier = modifier.height(4.dp)) Text( text = title, style = AppBarTypography.titleSmall, - modifier = modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth() ) - Spacer(modifier = modifier.height(8.dp)) + Spacer(modifier = modifier.height(12.dp)) Text( text = bottomDescription, - style = AppBarTypography.bodyMedium, + style = UlbanTypography.bodyMedium, modifier = modifier.fillMaxWidth() ) } diff --git a/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/appbar/UlbanDetailWithProgressAppBar.kt b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/appbar/UlbanDetailWithProgressAppBar.kt index f1d9b298..8990f21e 100644 --- a/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/appbar/UlbanDetailWithProgressAppBar.kt +++ b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/appbar/UlbanDetailWithProgressAppBar.kt @@ -9,6 +9,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp +import com.sixkids.designsystem.theme.RedDark import com.sixkids.designsystem.theme.component.progressbar.StudentProgressBar @Composable @@ -19,30 +20,34 @@ fun UlbanDetailWithProgressAppBar( content: String, topDescription: String, bottomDescription: String, + badgeCount: Int = 0, + totalCnt: Int, + successCnt: Int, color: Color, + progressBarColor: Color = RedDark, expanded: Boolean = true, onclick: () -> Unit, - totalCnt: Int, - successCnt: Int ) { BasicAppBar( modifier = modifier, leftIcon = leftIcon, title = title, content = { - Column ( + Column( modifier.fillMaxWidth(), horizontalAlignment = Alignment.Start - ){ + ) { AppBarDetailInfo( title = content, topDescription = topDescription, - bottomDescription = bottomDescription + bottomDescription = bottomDescription, + badgeCount = badgeCount ) StudentProgressBar( - modifier = modifier.padding(top= 8.dp , end = 16.dp), + modifier = modifier.padding(top = 8.dp, end = 16.dp), totalStudentCount = totalCnt, - successStudentCount = successCnt + successStudentCount = successCnt, + progressBarColor = progressBarColor ) } }, diff --git a/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/button/EditFAB.kt b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/button/EditFAB.kt new file mode 100644 index 00000000..bc048d4f --- /dev/null +++ b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/button/EditFAB.kt @@ -0,0 +1,39 @@ +package com.sixkids.designsystem.component.button + +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import com.sixkids.designsystem.R +import com.sixkids.designsystem.theme.Cream +import com.sixkids.designsystem.theme.Gray + +@Composable +fun EditFAB( + modifier: Modifier = Modifier, + buttonColor: Color = Cream, + iconColor: Color = Gray, + onClick: () -> Unit = {} +) { + FloatingActionButton( + modifier = modifier, + containerColor = buttonColor, + onClick = onClick, + ) { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_edit), + contentDescription = null, + tint = iconColor + ) + } +} + +@Preview(showBackground = true) +@Composable +fun EditFABPreview() { + EditFAB() +} \ No newline at end of file diff --git a/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/button/UlbanFilledButton.kt b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/button/UlbanFilledButton.kt new file mode 100644 index 00000000..599da145 --- /dev/null +++ b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/button/UlbanFilledButton.kt @@ -0,0 +1,66 @@ +package com.sixkids.designsystem.component.button + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.sixkids.designsystem.theme.Blue +import com.sixkids.designsystem.theme.BlueDark +import com.sixkids.designsystem.theme.Gray +import com.sixkids.designsystem.theme.UlbanTheme +import com.sixkids.designsystem.theme.UlbanTypography + +@Composable +fun UlbanFilledButton( + modifier: Modifier = Modifier, + text: String, + textStyle: TextStyle = UlbanTypography.titleSmall.copy(fontSize = 14.sp), + onClick: () -> Unit, + shape: Shape = RoundedCornerShape(12.dp), + color: Color = Blue, + elevation: Dp = 4.dp, + textColor: Color = BlueDark, + disabledTextColor: Color = Gray, + enabled: Boolean = true +) { + Button( + modifier = modifier, + onClick = onClick, + shape = shape, + colors = ButtonDefaults.buttonColors( + containerColor = color + ), + elevation = ButtonDefaults.buttonElevation( + defaultElevation = elevation + ), + enabled = enabled + ) { + Text( + text = text, color = if (enabled) textColor else disabledTextColor, + style = textStyle, + modifier = Modifier.padding(4.dp) + ) + + } +} + +@Preview(showBackground = true) +@Composable +fun UlbanFilledButtonPreview() { + UlbanTheme { + UlbanFilledButton( + text = "Button", + onClick = {}, + ) + } +} diff --git a/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/card/ContentCard.kt b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/card/ContentCard.kt index 800b1b3c..a2480f5a 100644 --- a/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/card/ContentCard.kt +++ b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/card/ContentCard.kt @@ -13,6 +13,8 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api @@ -25,14 +27,17 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight 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.dp import androidx.compose.ui.unit.sp import com.sixkids.designsystem.R import com.sixkids.designsystem.theme.Cream import com.sixkids.designsystem.theme.Red import com.sixkids.designsystem.theme.UlbanTheme +import com.sixkids.designsystem.theme.UlbanTypography import com.sixkids.designsystem.theme.Yellow import com.sixkids.designsystem.theme.component.progressbar.StudentProgressBar +import com.sixkids.designsystem.theme.npsFont import java.text.SimpleDateFormat import java.util.Locale @@ -94,7 +99,13 @@ fun ContentCardViewPreview() { @Composable fun RankCardViewPreview() { UlbanTheme { - RankCard() + ContentVerticalCard( + cardModifier = Modifier + .padding(20.dp) + .aspectRatio(1f), + imageDrawable = R.drawable.rank, + text = "랭킹" + ) } } @@ -107,15 +118,18 @@ enum class ContentAligment { @Composable fun ContentCard( modifier: Modifier = Modifier, + imageModifier: Modifier = Modifier, contentAligment: ContentAligment, cardColor: Color, contentName: String, + textColor: Color = Color.Black, @DrawableRes contentImageId: Int, runningState: RunningState? = null, - onclick: () -> Unit = {} + onclick: () -> Unit = {}, + cardHeight: Dp = 160.dp ) { Card( - modifier = modifier.height(160.dp), + modifier = modifier.height(cardHeight), colors = CardDefaults.cardColors( containerColor = cardColor ), @@ -123,6 +137,7 @@ fun ContentCard( defaultElevation = 4.dp, pressedElevation = 8.dp ), + shape = RoundedCornerShape(24.dp), onClick = onclick ) { Column { @@ -137,17 +152,21 @@ fun ContentCard( Image( painter = painterResource(id = contentImageId), contentDescription = null, - modifier = Modifier + modifier = imageModifier + .fillMaxHeight() .aspectRatio(1f) ) if (runningState == null) { Text( text = contentName, modifier = Modifier - .padding(20.dp), + .weight(1f) + .wrapContentHeight(), textAlign = TextAlign.Center, - fontSize = 30.sp, - fontWeight = FontWeight.Black + style = UlbanTypography.titleLarge.copy( + fontSize = 26.sp, + color = textColor + ) ) } else { RunningText( @@ -161,10 +180,14 @@ fun ContentCard( Text( text = contentName, modifier = Modifier - .padding(20.dp), +// .padding(20.dp) + .weight(1f) + .wrapContentHeight(), textAlign = TextAlign.Center, - fontSize = 30.sp, - fontWeight = FontWeight.Black + style = UlbanTypography.titleLarge.copy( + fontSize = 26.sp, + color = textColor + ) ) } else { RunningText( @@ -175,7 +198,7 @@ fun ContentCard( Image( painter = painterResource(id = contentImageId), contentDescription = null, - modifier = Modifier + modifier = imageModifier .fillMaxHeight() .aspectRatio(1f) ) @@ -219,12 +242,14 @@ fun RunningText( Text( text = boldText, fontSize = 18.sp, - fontWeight = FontWeight.Bold + fontWeight = FontWeight.Bold, + fontFamily = npsFont ) Text( modifier = Modifier.padding(top = 10.dp), text = normalText, fontSize = 14.sp, + fontFamily = npsFont ) } @@ -233,16 +258,18 @@ fun RunningText( @OptIn(ExperimentalMaterial3Api::class) @Composable -fun RankCard( - modifier: Modifier = Modifier, +fun ContentVerticalCard( + cardModifier: Modifier = Modifier, + cardColor: Color = Yellow, + textColor: Color = Color.Black, + imageDrawable: Int = R.drawable.rank, + text: String = "랭킹", onClick: () -> Unit = {} ) { Card( - modifier = modifier - .height(160.dp) - .aspectRatio(1f), + modifier = cardModifier, colors = CardDefaults.cardColors( - containerColor = Yellow + containerColor = cardColor ), elevation = CardDefaults.cardElevation( defaultElevation = 4.dp, @@ -258,13 +285,16 @@ fun RankCard( modifier = Modifier .weight(1f) .fillMaxWidth(), - painter = painterResource(id = R.drawable.rank), + painter = painterResource(id = imageDrawable), contentDescription = null, ) + + Spacer(modifier = Modifier.height(4.dp)) Text( - text = "랭크", - fontSize = 30.sp, - fontWeight = FontWeight.Black + text = text, + style = UlbanTypography.titleLarge.copy( + color = textColor + ) ) } } diff --git a/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/card/UlbanMissionCard.kt b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/card/UlbanMissionCard.kt new file mode 100644 index 00000000..1b1fc4e0 --- /dev/null +++ b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/card/UlbanMissionCard.kt @@ -0,0 +1,158 @@ +package com.sixkids.designsystem.component.card + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.GenericShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.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.platform.LocalDensity +import androidx.compose.ui.res.painterResource +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 com.sixkids.designsystem.R +import com.sixkids.designsystem.theme.Orange +import com.sixkids.designsystem.theme.Red +import com.sixkids.designsystem.theme.UlbanTheme +import com.sixkids.designsystem.theme.UlbanTypography + +@Composable +fun UlbanMissionCard( + modifier: Modifier = Modifier, + title: String = "", + subTitle: String = "", + @DrawableRes imgRes: Int = R.drawable.hifive, + backGroundColor: Color = Red, + onClick: () -> Unit = {} +) { + val density = LocalDensity.current + val cornerRadius = 16.dp // 둥근 모서리의 반경을 dp 단위로 설정 + val heightCut = 0.8f + val widthCUt = 0.7f + + val customShape = GenericShape { size, _ -> + with(density) { + val pxCornerRadius = cornerRadius.toPx() + moveTo(pxCornerRadius, 0f) // 시작 지점을 둥근 모서리의 반경만큼 오른쪽으로 이동합니다. + lineTo(size.width - pxCornerRadius, 0f) // 상단 가로선을 그립니다. + quadraticBezierTo(size.width, 0f, size.width, pxCornerRadius) // 오른쪽 상단 모서리 둥글게 처리 + lineTo(size.width, size.height * heightCut - pxCornerRadius) + quadraticBezierTo( + size.width, + size.height * heightCut, + size.width - pxCornerRadius, + size.height * heightCut + ) + lineTo(size.width * widthCUt + pxCornerRadius, size.height * heightCut) + quadraticBezierTo( + size.width * widthCUt, + size.height * heightCut, + size.width * widthCUt, + size.height * heightCut + pxCornerRadius + ) + lineTo(size.width * widthCUt, size.height - pxCornerRadius) + quadraticBezierTo( + size.width * widthCUt, + size.height, + size.width * widthCUt - pxCornerRadius, + size.height + ) + lineTo(pxCornerRadius, size.height) + quadraticBezierTo(0f, size.height, 0f, size.height - pxCornerRadius) // 왼쪽 하단 모서리 둥글게 처리 + lineTo(0f, pxCornerRadius) // 왼쪽 세로선을 그립니다. + quadraticBezierTo(0f, 0f, pxCornerRadius, 0f) // 왼쪽 상단 모서리 둥글게 처리 + close() + } + } + Box( + modifier = modifier.clickable { + onClick() + } + ) { + Card( + shape = customShape, + colors = CardDefaults.cardColors( + containerColor = backGroundColor, + ), + modifier = Modifier + .padding(40.dp) + .width(200.dp) + .height(300.dp) + ) { + Column { + Spacer(modifier = modifier.height(170.dp)) + Text( + modifier = Modifier.fillMaxWidth(), + text = title, + style = UlbanTypography.titleSmall, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.weight(1f)) + Row( + modifier = Modifier.height(50.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.weight(widthCUt), + text = subTitle, + style = UlbanTypography.titleSmall.copy(fontSize = 12.sp), + color = Color.Gray, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.weight(1 - widthCUt)) + } + } + } + Box( + modifier = Modifier + .align(Alignment.TopCenter), + contentAlignment = Alignment.Center + ) { + Image( + modifier = Modifier.run { size(230.dp).offset(x = -(20.dp), y = -(20.dp)) }, + painter = painterResource(id = imgRes), + contentDescription = null + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun UlbanMissionCardPreview() { + UlbanTheme { + UlbanMissionCard( + title = "4월 22일 깜짝 미션", + subTitle = "상세 정보", + imgRes = R.drawable.hifive, + backGroundColor = Red + ) + } +} + +@Preview(showBackground = true) +@Composable +fun UlbanPreview2(){ + UlbanMissionCard( + imgRes = R.drawable.relay, + title = "친구에게 전달해 봐요!", + backGroundColor = Orange + ) +} \ No newline at end of file diff --git a/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/checkbox/TextRadioButton.kt b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/checkbox/TextRadioButton.kt new file mode 100644 index 00000000..b3e88c02 --- /dev/null +++ b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/checkbox/TextRadioButton.kt @@ -0,0 +1,57 @@ +package com.sixkids.designsystem.component.checkbox + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.selection.selectable +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.sixkids.designsystem.theme.UlbanTypography + +@Composable +fun TextRadioButton( + selected: Boolean = false, + onClick: () -> Unit = { }, + text: String = "" +) { + Row( + Modifier + .fillMaxWidth() + .selectable( + selected = selected, + onClick = onClick, + role = Role.RadioButton, + ), + verticalAlignment = Alignment.CenterVertically + ) { + // 익명 체크박스 + RadioButton( + modifier = Modifier + .scale(1.2f) + .requiredSize(30.dp), + selected = selected, + onClick = null, + ) + if(text.isNotEmpty()) { + Text( + text = text, + style = UlbanTypography.bodyLarge + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun TextCheckBoxPreview() { + TextRadioButton( + text = "라디오버튼" + ) +} diff --git a/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/dialog/UlbanBisicDialog.kt b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/dialog/UlbanBisicDialog.kt new file mode 100644 index 00000000..768c9984 --- /dev/null +++ b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/dialog/UlbanBisicDialog.kt @@ -0,0 +1,55 @@ +package com.sixkids.designsystem.component.dialog + +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.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.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.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.sixkids.designsystem.theme.Cream +import com.sixkids.designsystem.theme.UlbanTheme + +@Composable +fun UlbanBasicDialog( + modifier: Modifier = Modifier, + onDismiss: () -> Unit = {}, + backGroundColor: Color = Cream, + content: @Composable () -> Unit +) { + Dialog(onDismissRequest = onDismiss) { + Card( + modifier = modifier, + colors = CardDefaults.cardColors(containerColor = backGroundColor), + shape = RoundedCornerShape(16.dp) + ) { + Column( + modifier = modifier + .padding(16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + content() + } + } + } +} + + +@Preview(showBackground = true) +@Composable +fun UlbalDialogPreview() { + UlbanTheme { + UlbanBasicDialog { + Text(text = "Hello World") + } + } +} diff --git a/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/dialog/UlbanDatePickerDialog.kt b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/dialog/UlbanDatePickerDialog.kt new file mode 100644 index 00000000..f8bc2300 --- /dev/null +++ b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/dialog/UlbanDatePickerDialog.kt @@ -0,0 +1,104 @@ +package com.sixkids.designsystem.component.dialog + +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Button +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDefaults +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.DisplayMode +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.sixkids.designsystem.R +import com.sixkids.designsystem.theme.Cream +import com.sixkids.designsystem.theme.UlbanTheme +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun UlbanDatePickerDialog( + selectedDate: LocalDate = LocalDate.now(), + onDismiss: () -> Unit, + onClickConfirm: (date: LocalDate) -> Unit +) { + + val datePickerState = rememberDatePickerState( + initialDisplayMode = DisplayMode.Picker, + initialSelectedDateMillis = selectedDate.plusDays(1) + .atStartOfDay(ZoneId.systemDefault()).toInstant() + .toEpochMilli() + ) + + + DatePickerDialog( + onDismissRequest = { onDismiss() }, confirmButton = { + TextButton( + onClick = { + onClickConfirm(datePickerState.selectedDateMillis?.let { + Instant.ofEpochMilli(it).atZone(ZoneId.systemDefault()).toLocalDate() + } ?: LocalDate.now()) + }, + ) { + Text(stringResource(R.string.confirm)) + } + }, dismissButton = { + TextButton(onClick = { + onDismiss() + }) { + Text(stringResource(R.string.cancel)) + } + }, colors = DatePickerDefaults.colors( + containerColor = Cream + ) + ) { + DatePicker( + state = datePickerState, + ) + } +} + + +@Preview +@Composable +fun CustomDatePickerDialogPreview() { + UlbanTheme { + var selectedDate by remember { mutableStateOf(LocalDate.now()) } + var showDialog by remember { mutableStateOf(false) } + Column { + Text( + text = selectedDate.toString(), + ) + if (showDialog) { + UlbanDatePickerDialog( + selectedDate = selectedDate, + onDismiss = { + showDialog = false + }, + onClickConfirm = { + selectedDate = it + showDialog = false + } + ) + } + Button( + onClick = { + showDialog = true + } + ) { + Text("날짜 선택") + } + } + } + +} diff --git a/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/dialog/UlbanTimePickerDialog.kt b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/dialog/UlbanTimePickerDialog.kt new file mode 100644 index 00000000..502db34f --- /dev/null +++ b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/dialog/UlbanTimePickerDialog.kt @@ -0,0 +1,87 @@ +package com.sixkids.designsystem.component.dialog + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TimePicker +import androidx.compose.material3.rememberTimePickerState +import androidx.compose.runtime.Composable +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.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.sixkids.designsystem.R +import com.sixkids.designsystem.theme.UlbanTheme +import java.time.LocalTime + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun UlbanTimePickerDialog( + modifier: Modifier = Modifier, + onDismiss: () -> Unit = {}, + onClickConfirm: (time: LocalTime) -> Unit, + selectedTime: LocalTime = LocalTime.now(), +) { + UlbanBasicDialog( + modifier = modifier, + onDismiss = onDismiss, + ) { + val timePickerState = rememberTimePickerState( + initialHour = selectedTime.hour, + initialMinute = selectedTime.minute + ) + + Column( + modifier = modifier, + verticalArrangement = Arrangement.Center, + ) { + TimePicker(state = timePickerState) + Row( + modifier = modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + TextButton(onClick = onDismiss) { + Text(stringResource(id = R.string.cancel)) + } + TextButton( + onClick = { + onClickConfirm(LocalTime.of(timePickerState.hour, timePickerState.minute)) + } + ) { + Text(stringResource(id = R.string.confirm)) + } + } + } + + } +} + + +@Preview(showBackground = true) +@Composable +fun UlbanTimePickerDialogPreview() { + + UlbanTheme { + var selectedTime by remember { mutableStateOf(LocalTime.now()) } + var showDialog by remember { mutableStateOf(false) } + + UlbanTimePickerDialog( + selectedTime = selectedTime, + onDismiss = { + showDialog = false + }, + onClickConfirm = { + selectedTime = it + showDialog = false + } + ) + } +} diff --git a/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/item/MemberSimpleItem.kt b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/item/MemberSimpleItem.kt new file mode 100644 index 00000000..c86dfe98 --- /dev/null +++ b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/item/MemberSimpleItem.kt @@ -0,0 +1,71 @@ +package com.sixkids.designsystem.component.item + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.sixkids.designsystem.R +import com.sixkids.designsystem.theme.UlbanTypography + + +@Composable +fun MemberSimpleItem( + modifier: Modifier = Modifier, + member: DisplayableMember, +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally + ) { + AsyncImage( + modifier = Modifier + .size(60.dp) + .clip(CircleShape), + model = member.photo, + contentScale = ContentScale.Crop, + contentDescription = "프로필 사진" + ) + Row( + verticalAlignment = Alignment.CenterVertically + ) { + if (member.isLeader) { + Image( + painter = painterResource(id = R.drawable.crown), + contentDescription = "리더 아이콘", + modifier = Modifier.size(16.dp) + ) + } + Text(text = member.name, style = UlbanTypography.bodySmall) + } + } +} + +interface DisplayableMember { + val name: String + val photo: String + val isLeader: Boolean +} + + +@Preview(showBackground = true) +@Composable +fun MemberSimpleItemPreview() { + MemberSimpleItem( + member = object : DisplayableMember { + override val name: String = "김철수" + override val photo: String = "" + override val isLeader: Boolean = true + } + ) +} diff --git a/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/item/StudentSimpleCardItem.kt b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/item/StudentSimpleCardItem.kt new file mode 100644 index 00000000..5cee18b7 --- /dev/null +++ b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/item/StudentSimpleCardItem.kt @@ -0,0 +1,99 @@ +package com.sixkids.designsystem.component.item + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.sixkids.designsystem.theme.Cream +import com.sixkids.designsystem.theme.UlbanTypography + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StudentSimpleCardItem( + modifier: Modifier = Modifier, + id: Long = 0, + name: String = "", + photo: String = "", + score: Int? = null, + onClick: (Long) -> Unit = {}, + isCount: Boolean = false +) { + Card( + modifier = modifier, + colors = CardDefaults.cardColors( + containerColor = Cream + ), + elevation = CardDefaults.cardElevation( + defaultElevation = 4.dp, + pressedElevation = 8.dp + ), + onClick = {onClick(id)} + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + AsyncImage( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .padding(8.dp) + .clip(RoundedCornerShape(16.dp)), + model = photo, + + contentScale = ContentScale.Crop, + contentDescription = "프로필 사진" + ) + Text( + text = name, + style = UlbanTypography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(bottom = 4.dp) + ) + if (score != null) { + Text( + text = if (isCount)"${score}회" else "${score}점", + style = UlbanTypography.bodySmall + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun StudentSimpleCardItemPreview() { + LazyVerticalGrid( + columns = GridCells.Fixed(3), + ) { + items(25){ + StudentSimpleCardItem( + modifier = Modifier.padding(4.dp), + name = "김철수", +// photo = "https://i.pinimg.com/564x/f9/e5/c1/f9e5c19d2a51bda108e5ea536d7745c1.jpg", + score = 97 + ) + } + } +} diff --git a/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/item/UlbanChallengeItem.kt b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/item/UlbanChallengeItem.kt new file mode 100644 index 00000000..65c16671 --- /dev/null +++ b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/item/UlbanChallengeItem.kt @@ -0,0 +1,103 @@ +package com.sixkids.designsystem.component.item + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.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.unit.dp +import com.sixkids.designsystem.R +import com.sixkids.designsystem.theme.Cream +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.ui.util.formatToMonthDayTime +import java.time.LocalDateTime + +@Composable +fun UlbanChallengeItem( + modifier: Modifier = Modifier, + padding: PaddingValues = PaddingValues( + horizontal = 16.dp, + vertical = 16.dp + ), + title: String, + description: String, + startDate: LocalDateTime, + endDate: LocalDateTime, + userCount: Int, + color: Color = Cream, + onClick: () -> Unit = {} +) { + Card( + modifier = modifier + .fillMaxWidth() + .clickable { + onClick() + }, + colors = CardDefaults.cardColors( + containerColor = color + ), + elevation = CardDefaults.cardElevation( + defaultElevation = 8.dp + ), + shape = CardDefaults.outlinedShape, + ) { + Column( + modifier = modifier.padding( + padding + ) + ) { + Text( + text = title, + style = UlbanTypography.titleSmall + ) + Spacer(modifier = modifier.padding(8.dp)) + Text( + text = description, + style = UlbanTypography.bodySmall + ) + Spacer(modifier = modifier.padding(8.dp)) + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + modifier = modifier.size(32.dp), + painter = painterResource(id = R.drawable.member), + contentDescription = null, + tint = Color.Unspecified + ) + Text(text = stringResource(id = R.string.hifive_user_count, userCount)) + } + Spacer(modifier = modifier.padding(4.dp)) + Row { + Text( + text = startDate.formatToMonthDayTime(), + style = UlbanTypography.bodySmall + ) + Text( + text = " ~ ", + style = UlbanTypography.bodySmall + ) + Text( + text = endDate.formatToMonthDayTime(), + style = UlbanTypography.bodySmall + ) + } + + } + + } +} diff --git a/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/item/UlbanRelayItem.kt b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/item/UlbanRelayItem.kt new file mode 100644 index 00000000..eacd5b89 --- /dev/null +++ b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/item/UlbanRelayItem.kt @@ -0,0 +1,110 @@ +package com.sixkids.designsystem.component.item + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.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 coil.compose.AsyncImage +import com.sixkids.designsystem.R +import com.sixkids.designsystem.theme.Cream +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.ui.util.formatToMonthDayTime +import java.time.LocalDateTime + +@Composable +fun UlbanRelayItem( + modifier: Modifier = Modifier, + padding: PaddingValues = PaddingValues( + horizontal = 16.dp, + vertical = 16.dp + ), + startDate: LocalDateTime, + endDate: LocalDateTime, + userCount: Int, + color: Color = Cream, + lastMemberName: String, + onClick: () -> Unit = {} +) { + Card( + modifier = modifier + .fillMaxWidth() + .clickable { + onClick() + }, + colors = CardDefaults.cardColors( + containerColor = color + ), + elevation = CardDefaults.cardElevation( + defaultElevation = 8.dp + ), + shape = CardDefaults.outlinedShape, + ) { + Column( + modifier = modifier.padding( + padding + ) + ) { + Text( + text = stringResource(R.string.relay), + style = UlbanTypography.titleSmall + ) + Spacer(modifier = modifier.padding(8.dp)) + + Text( + text = "${startDate.formatToMonthDayTime()} ~ ${endDate.formatToMonthDayTime()}", + style = UlbanTypography.bodySmall + ) + Spacer(modifier = modifier.padding(8.dp)) + + Row (modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically){ + AsyncImage( + model = R.drawable.bomb, + placeholder = painterResource(id =R.drawable.bomb), + modifier = Modifier.size(42.dp), + contentDescription = "bomb") + + Spacer(modifier = modifier.padding(4.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.relay_item_count, userCount), + style = UlbanTypography.bodySmall + ) + Spacer(modifier = modifier.padding(2.dp)) + Text( + text = stringResource(R.string.relay_item_last_member, lastMemberName), + style = UlbanTypography.bodyMedium + ) + } + } + + } + } + +} + +@Composable +@Preview(showBackground = true) +fun UlbanChallengeItemPreview() { + UlbanRelayItem( + startDate = LocalDateTime.now(), + endDate = LocalDateTime.now(), + userCount = 0, + lastMemberName = "홍유준" + ) +} diff --git a/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/item/UlbanReportItem.kt b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/item/UlbanReportItem.kt new file mode 100644 index 00000000..0db54da4 --- /dev/null +++ b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/item/UlbanReportItem.kt @@ -0,0 +1,174 @@ +package com.sixkids.designsystem.component.item + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +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.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.sixkids.designsystem.theme.Blue +import com.sixkids.designsystem.theme.BlueDark +import com.sixkids.designsystem.theme.Cream +import com.sixkids.designsystem.theme.Red +import com.sixkids.designsystem.theme.RedDark +import com.sixkids.designsystem.theme.UlbanTheme +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.ui.util.formatToMonthDayTime +import java.time.LocalDateTime + +@Composable +fun UlbanReportItem( + modifier: Modifier = Modifier, + padding: PaddingValues = PaddingValues(16.dp), + startDate: LocalDateTime = LocalDateTime.now(), + endDate: LocalDateTime = LocalDateTime.now(), + content: String = "", + file: String = "", + accepted: Boolean = false, + memberList: List = emptyList(), + onReject: () -> Unit = {}, + onAccept: () -> Unit = {}, +) { + Card( + modifier = modifier + .fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = Cream + ), + elevation = CardDefaults.cardElevation( + defaultElevation = 8.dp + ), + shape = CardDefaults.outlinedShape + ) { + Column( + modifier = modifier + .padding(padding) + ) { + Spacer(modifier = modifier.padding(4.dp)) + Row { + Text( + text = startDate.formatToMonthDayTime(), + style = UlbanTypography.bodySmall + ) + Text( + text = " ~ ", + style = UlbanTypography.bodySmall + ) + Text( + text = endDate.formatToMonthDayTime(), + style = UlbanTypography.bodySmall + ) + } + AsyncImage( + model = file, + contentDescription = "과제 사진", + modifier = modifier + .fillMaxWidth() + .height(240.dp) + .padding(vertical = 8.dp) + ) + MemberSimpleList( + modifier = modifier.padding(vertical = 8.dp), + memberList = memberList + ) + Text(text = content, style = UlbanTypography.bodySmall) + if (accepted.not()) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(top = 16.dp) + ) { + Button( + modifier = modifier.weight(1f), + colors = ButtonDefaults.buttonColors(containerColor = Red), + onClick = { onReject() }) { + Text(text = "X", style = UlbanTypography.bodyLarge, color = RedDark) + } + Spacer(modifier = modifier.width(8.dp)) + Button( + modifier = modifier.weight(1f), + colors = ButtonDefaults.buttonColors(containerColor = Blue), + onClick = { onAccept() }) { + Text(text = "O", style = UlbanTypography.bodyLarge, color = BlueDark) + } + } + } + } + + } +} + +@Composable +fun MemberSimpleList( + modifier: Modifier = Modifier, + memberList: List = emptyList(), +) { + LazyRow( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(memberList) { member -> + MemberSimpleItem( + member = member, + ) + } + + } +} + + +@Preview(showBackground = true, showSystemUi = true) +@Composable +fun UlbanReportItemPreview() { + + UlbanTheme { + UlbanReportItem( + content = "4명 다 모여서 쿵푸팬더 4 다같이 봤어요!!", + startDate = LocalDateTime.now(), + endDate = LocalDateTime.now(), + accepted = false, + file = "https://file2.nocutnews.co.kr/newsroom/image/2024/04/05/202404052218304873_0.jpg", + memberList = listOf( + object : DisplayableMember { + override val name: String = "김규리" + override val photo: String = + "https://encrypted-tbn3.gstatic.com/images?q=tbn:ANd9GcSGfpQ3m-QWiXgCBJJbrcUFdNdWAhj7rcUqjeNUC6eKcXZDAtWm" + override val isLeader: Boolean = true + }, + object : DisplayableMember { + override val name: String = "오하빈" + override val photo: String = + "https://health.chosun.com/site/data/img_dir/2023/07/17/2023071701753_0.jpg" + override val isLeader: Boolean = false + }, + object : DisplayableMember { + override val name: String = "차성원" + override val photo: String = + "https://ichef.bbci.co.uk/ace/ws/800/cpsprodpb/E172/production/_126241775_getty_cats.png" + override val isLeader: Boolean = false + }, + object : DisplayableMember { + override val name: String = "정철주" + override val photo: String = + "https://image.newsis.com/2023/07/12/NISI20230712_0001313626_web.jpg?rnd=20230712163021" + override val isLeader: Boolean = false + } + ) + ) + } +} diff --git a/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/item/UlbanRunnerItem.kt b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/item/UlbanRunnerItem.kt new file mode 100644 index 00000000..480f012b --- /dev/null +++ b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/item/UlbanRunnerItem.kt @@ -0,0 +1,131 @@ +package com.sixkids.designsystem.component.item + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.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.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.sixkids.designsystem.R +import com.sixkids.designsystem.theme.Cream +import com.sixkids.designsystem.theme.Gray +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.ui.util.formatToMonthDayTime +import java.time.LocalDateTime + +@Composable +fun UlbanRunnerItem( + modifier: Modifier = Modifier, + padding: PaddingValues = PaddingValues( + horizontal = 16.dp, + vertical = 20.dp + ), + time: LocalDateTime, + memberName: String, + memberPhoto: String, + question: String, + isLastTurn: Boolean = false, + color: Color = Cream +) { + Column(modifier = modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + Box(modifier = Modifier.wrapContentSize().padding(bottom = 10.dp)) { + Card( + colors = CardDefaults.cardColors( + containerColor = color + ), + elevation = CardDefaults.cardElevation( + defaultElevation = 8.dp + ), + shape = CardDefaults.outlinedShape, + ) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(padding), + verticalAlignment = Alignment.CenterVertically + ) { + AsyncImage( + model = memberPhoto, + placeholder = painterResource(id = R.drawable.student_boy), + contentDescription = null, + modifier = Modifier.size(80.dp), + contentScale = ContentScale.Crop + ) + Column( + modifier = Modifier.padding(start = 16.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text( + text = memberName, + style = UlbanTypography.titleSmall + ) + Text( + text = time.formatToMonthDayTime(), + style = UlbanTypography.bodySmall + ) + Text( +// text = stringResource(R.string.runner_question), + text = "받은 질문", + style = UlbanTypography.bodySmall.copy(color = Gray) + ) + Text( + text = question, + style = UlbanTypography.bodyMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } + } + if (isLastTurn) { + AsyncImage( + model = R.drawable.bomb, + placeholder = painterResource(id = R.drawable.bomb), + contentDescription = "bomb", + modifier = Modifier + .size(72.dp) + .align(Alignment.TopEnd) + .padding(12.dp) + ) + } + } + if (!isLastTurn) { + Image( + painter = painterResource(id = R.drawable.ic_down_arrow), + contentDescription = "next", + modifier = Modifier + .padding(10.dp) + .size(32.dp) + ) + } + } + +} + +@Composable +@Preview(showBackground = true) +fun UlbanRunnerItemPreview() { + UlbanRunnerItem( + time = LocalDateTime.now(), + memberName = "홍유준", + memberPhoto = "https://ulvanbucket.s3.ap-northeast-2.amazonaws.com/c5cef8d2-f085-45ca-b359-f76e39f2bca3_profile.jpg", + question = "이번 턴은 어떤" + ) +} \ No newline at end of file diff --git a/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/progressbar/StudentProgressBar.kt b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/progressbar/StudentProgressBar.kt index 3dc02f4b..e0d282ef 100644 --- a/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/progressbar/StudentProgressBar.kt +++ b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/progressbar/StudentProgressBar.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.sixkids.designsystem.theme.RedDark +import com.sixkids.designsystem.theme.UlbanTypography @Preview(showBackground = true, backgroundColor = 0xFFFFF6F0) @Composable @@ -29,7 +30,8 @@ fun StudentProgressBarPreview() { fun StudentProgressBar( modifier: Modifier = Modifier, totalStudentCount: Int, - successStudentCount: Int + successStudentCount: Int, + progressBarColor: Color = RedDark ){ Column( modifier = modifier, @@ -37,10 +39,11 @@ fun StudentProgressBar( LinearProgressIndicator( modifier = Modifier .fillMaxWidth() + .padding(bottom = 4.dp) .height(4.dp), progress = (successStudentCount.toFloat()/totalStudentCount.toFloat()), trackColor = Color.White, - color = RedDark + color = progressBarColor ) Text( modifier = Modifier @@ -48,6 +51,7 @@ fun StudentProgressBar( text = "${totalStudentCount}명 중 ${successStudentCount}명이 진행했어요", textAlign = TextAlign.End, fontSize = 14.sp, + style = UlbanTypography.bodySmall ) } } \ No newline at end of file diff --git a/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/screen/GreetingScreen.kt b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/screen/GreetingScreen.kt new file mode 100644 index 00000000..adf59e6e --- /dev/null +++ b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/screen/GreetingScreen.kt @@ -0,0 +1,71 @@ +package com.sixkids.designsystem.component.screen + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +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.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.sixkids.designsystem.R +import com.sixkids.designsystem.component.button.UlbanFilledButton +import com.sixkids.designsystem.theme.UlbanTypography + +@Composable +fun GreetingScreen( + isSender: Boolean = true, + onClick: () -> Unit = {} +) { + val title = + if (isSender) "친구에게 인사 보내기" + else "친구의 인사 받기" + + + val body = + if (isSender) "친구와 핸드폰 뒷면을 맞대서 인사를 건네요" + else "친구와 핸드폰 뒷면을 맞대서 인사를 받아요" + + Column( + modifier = Modifier.fillMaxSize() + ) { + Text( + text = title, + style = UlbanTypography.titleMedium, + modifier = Modifier.padding(30.dp, 40.dp, 30.dp, 15.dp) + ) + + + Spacer(modifier = Modifier.height(100.dp)) + + Image(painter = painterResource(id = R.drawable.relay_tag), contentDescription = "tagging", + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + ) + + + Text( + text = body, style = UlbanTypography.bodyMedium, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + + Spacer(modifier = Modifier.weight(1f)) + if (isSender) { + UlbanFilledButton( + text = "완료", + onClick = { onClick() }, + modifier = Modifier + .padding(horizontal = 30.dp, vertical = 40.dp) + .fillMaxWidth() + ) + } + } +} \ No newline at end of file diff --git a/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/screen/LoadingScreen.kt b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/screen/LoadingScreen.kt new file mode 100644 index 00000000..3a54283e --- /dev/null +++ b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/screen/LoadingScreen.kt @@ -0,0 +1,46 @@ +package com.sixkids.designsystem.component.screen + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.airbnb.lottie.compose.LottieAnimation +import com.airbnb.lottie.compose.LottieCompositionSpec +import com.airbnb.lottie.compose.rememberLottieAnimatable +import com.airbnb.lottie.compose.rememberLottieComposition +import com.sixkids.designsystem.R +import com.sixkids.designsystem.theme.LoadingBackground + +@Composable +fun LoadingScreen() { + val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.lodaing)) + val lottieAnimatable = rememberLottieAnimatable() + + LaunchedEffect(composition) { + lottieAnimatable.animate( + composition = composition, + initialProgress = 0f + ) + } + + + Box( + modifier = Modifier + .fillMaxSize() + .background(LoadingBackground), + contentAlignment = Alignment.Center, + ) { + LottieAnimation( + composition = composition, + modifier = Modifier.size(150.dp).aspectRatio(1f), + iterations = Int.MAX_VALUE + ) + } +} diff --git a/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/screen/RelayPassResultScreen.kt b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/screen/RelayPassResultScreen.kt new file mode 100644 index 00000000..7d1ddff8 --- /dev/null +++ b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/screen/RelayPassResultScreen.kt @@ -0,0 +1,153 @@ +package com.sixkids.designsystem.component.screen + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +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 com.sixkids.designsystem.R +import com.sixkids.designsystem.component.button.UlbanFilledButton +import com.sixkids.designsystem.theme.Orange +import com.sixkids.designsystem.theme.Purple +import com.sixkids.designsystem.theme.UlbanTheme +import com.sixkids.designsystem.theme.UlbanTypography + +@Composable +fun RelayPassResultScreen( + paddingValues: PaddingValues = PaddingValues(horizontal = 30.dp, vertical = 40.dp), + title: String = stringResource(R.string.relay_pass_result_title), + subTitle: String = stringResource(R.string.relay_pass_result_subtitle_sender), + bodyTop: String = stringResource(R.string.relay_pass_result_body_top_receiver), + bodyMiddle: String, + bodyBottom: String = stringResource(R.string.relay_pass_result_body_bottom_sender), + @DrawableRes imgRes: Int = R.drawable.relay_success, + backgroundColor: Color = Orange, + onClick: () -> Unit = {} +) { + val screenWidthDp = with(LocalDensity.current) { + LocalContext.current.resources.displayMetrics.widthPixels.toDp() + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + ) { + Text( + text = title, + style = UlbanTypography.titleMedium, + modifier = Modifier.padding(bottom = 15.dp) + ) + + Text(text = subTitle, style = UlbanTypography.bodyMedium) + + Spacer(modifier = Modifier.height(100.dp)) + + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + Box(modifier = Modifier.align(Alignment.Center)) { + Box( + modifier = Modifier + .size(screenWidthDp / 2) + .clip(RoundedCornerShape(28.dp)) + .background(backgroundColor) + .align(Alignment.Center) + ) + + Image( + painter = painterResource(id = imgRes), + contentDescription = "success", + modifier = Modifier + .size(screenWidthDp / 2 + screenWidthDp / 7) + .offset(y = -screenWidthDp / 10) + .align(Alignment.Center) + ) + } + } + + Column( + modifier = Modifier.align(Alignment.CenterHorizontally), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = bodyTop, + style = UlbanTypography.bodyLarge, + modifier = Modifier.padding(top = 30.dp), + textAlign = TextAlign.Center + ) + + Text( + text = bodyMiddle, + style = UlbanTypography.titleMedium.copy(lineHeight = 30.sp), + modifier = Modifier.padding(top = 30.dp, start = 20.dp, end = 20.dp), + textAlign = TextAlign.Center + ) + + Text( + text = bodyBottom, + style = UlbanTypography.bodyLarge, + modifier = Modifier.padding(top = 30.dp), + textAlign = TextAlign.Center + ) + + } + + Spacer(modifier = Modifier.weight(1f)) + + UlbanFilledButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.relay_pass_result_button_done), + onClick = { onClick() } + ) + + } +} + +@Composable +@Preview(showBackground = true) +fun RelayPassResultScreenPreview() { + UlbanTheme { + RelayPassResultScreen( + bodyTop = "오하빈 학생이", + bodyMiddle = "\"우리반에서 가장 공부를 잘 하는 친구는 누구야?\"", + ) + } +} + +@Composable +@Preview(showBackground = true) +fun RelayPassResultBombScreenPreview() { + UlbanTheme { + RelayPassResultScreen( + title = "이런! 폭탄이 터졌어요", + subTitle = "-100 exp", + bodyTop = "오하빈 학생이", + bodyMiddle = "\"우리반에서 가장 공부를 잘 하는 친구는 누구야?\"", + imgRes = R.drawable.bomb, + backgroundColor = Purple + ) + } +} \ No newline at end of file diff --git a/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/screen/RelayTaggingScreen.kt b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/screen/RelayTaggingScreen.kt new file mode 100644 index 00000000..89649fbd --- /dev/null +++ b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/screen/RelayTaggingScreen.kt @@ -0,0 +1,86 @@ +package com.sixkids.designsystem.component.screen + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +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.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +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 com.sixkids.designsystem.R +import com.sixkids.designsystem.component.button.UlbanFilledButton +import com.sixkids.designsystem.theme.UlbanTypography + +@Composable +fun RelayTaggingScreen( + isSender: Boolean = true, + onClick: () -> Unit = {} +) { + val title = + if (isSender) stringResource(R.string.relay_tagging_title_sender) + else stringResource(R.string.relay_tagging_title_receiver) + + val subTitle = + if (isSender) stringResource(R.string.relay_tagging_sub_title_sender) + else stringResource(R.string.relay_tagging_sub_title_receiver) + + val body = + if (isSender) stringResource(R.string.relay_tagging_body_sender) + else stringResource(R.string.relay_tagging_body_receiver) + + Column( + modifier = Modifier.fillMaxSize() + ) { + Text( + text = title, + style = UlbanTypography.titleMedium, + modifier = Modifier.padding(30.dp, 40.dp, 30.dp, 15.dp) + ) + + Text( + text = subTitle, style = UlbanTypography.bodyMedium, + modifier = Modifier.padding(start = 30.dp) + ) + + Spacer(modifier = Modifier.height(100.dp)) + + Image(painter = painterResource(id = R.drawable.relay_tag), contentDescription = "tagging", + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + ) + + + Text( + text = body, style = UlbanTypography.bodyMedium, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + + Spacer(modifier = Modifier.weight(1f)) + if (isSender) { + UlbanFilledButton( + text = "다음", + onClick = { onClick() }, + modifier = Modifier + .padding(horizontal = 30.dp, vertical = 40.dp) + .fillMaxWidth() + ) + } + } +} + +@Composable +@Preview(showBackground = true) +fun RelayTaggingScreenPreview() { + RelayTaggingScreen(false) +} \ No newline at end of file diff --git a/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/screen/UlbanTopSectionScreen.kt b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/screen/UlbanTopSectionScreen.kt new file mode 100644 index 00000000..6586c290 --- /dev/null +++ b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/screen/UlbanTopSectionScreen.kt @@ -0,0 +1,35 @@ +package com.sixkids.designsystem.component.screen + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.sixkids.designsystem.R +import com.sixkids.designsystem.theme.UlbanTypography + +@Composable +fun UlbanTopSection(text: String = "", onBackClick: () -> Unit = {}) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.Start, + ) { + Image( + painter = painterResource(id = R.drawable.ic_arrow_back), + contentDescription = "back button", + modifier = Modifier.clickable { onBackClick() } + ) + + Text( + text = text, + style = UlbanTypography.titleMedium, + modifier = Modifier.padding(top = 20.dp) + ) + } +} \ No newline at end of file diff --git a/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/snackbar/UlbanSnackbar.kt b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/snackbar/UlbanSnackbar.kt new file mode 100644 index 00000000..0a58f895 --- /dev/null +++ b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/snackbar/UlbanSnackbar.kt @@ -0,0 +1,134 @@ +package com.sixkids.designsystem.component.snackbar + +import androidx.annotation.DrawableRes +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.sixkids.designsystem.R +import com.sixkids.designsystem.theme.Cream +import com.sixkids.designsystem.theme.RedDark +import com.sixkids.designsystem.theme.UlbanTheme +import com.sixkids.designsystem.theme.UlbanTypography + +@Composable +fun UlbanSnackbar( + modifier: Modifier = Modifier, + visible: Boolean, + message: String, + @DrawableRes actionIconId: Int? = null, + actionButtonText: String? = null, + onClickActionButton: () -> Unit = {}, +) { + AnimatedVisibility( + visible = visible, + enter = fadeIn(), + exit = fadeOut(), + ) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.BottomCenter, + ) { + UlbanSnackbarContent( + modifier = Modifier, + message = message, + actionIconId = actionIconId, + actionButtonText = actionButtonText, + onClickActionButton = onClickActionButton, + ) + } + } +} + +@Composable +private fun UlbanSnackbarContent( + modifier: Modifier = Modifier, + message: String, + @DrawableRes actionIconId: Int? = null, + actionButtonText: String? = null, + onClickActionButton: () -> Unit = {}, +) { + Row( + modifier = modifier + .padding(8.dp) + .fillMaxWidth() + .background(color = Cream, shape = RoundedCornerShape(4.dp)) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Text( + modifier = modifier.weight(1f), text = message, + style = UlbanTypography.bodyMedium + ) + + if (actionIconId != null) { + Icon( + painter = painterResource(id = actionIconId), + contentDescription = null, + modifier = Modifier + .size(24.dp) + .clickable { onClickActionButton() }, + tint = RedDark + ) + } + + if (actionButtonText != null) { + Text( + modifier = Modifier + .clickable( + onClick = onClickActionButton, + ), + text = actionButtonText, + style = UlbanTypography.bodySmall.copy(color = RedDark) + ) + } + } +} + + +@Preview +@Composable +fun UlbanSnackbarPreview() { + UlbanTheme { + Column { + UlbanSnackbarContent(message = "This is a snackbar") + UlbanSnackbarContent(message = "This is a snackbar This is a snackbar This is a snackbar ") + + UlbanSnackbarContent(message = "This is a snackbar", actionButtonText = "Action") + + UlbanSnackbarContent( + message = "This is a snackbar", + actionIconId = R.drawable.ic_arrow_back + ) + + UlbanSnackbarContent( + message = "This is a snackbar This is a snackbar This is a snackbar ", + actionButtonText = "Action" + ) + + UlbanSnackbarContent( + message = "This is a snackbar This is a snackbar This is a snackbar ", + actionIconId = R.drawable.ic_arrow_back + ) + } + } +} diff --git a/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/textfield/NumberVisualTransformation.kt b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/textfield/NumberVisualTransformation.kt new file mode 100644 index 00000000..96cefa47 --- /dev/null +++ b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/textfield/NumberVisualTransformation.kt @@ -0,0 +1,50 @@ +package com.sixkids.designsystem.component.textfield + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation +import java.text.DecimalFormat + +class NumberVisualTransformation( + private val postfix: String, +) : VisualTransformation { + override fun filter(text: AnnotatedString): TransformedText { + val amount = text.text + + if(amount.isEmpty()) { + return TransformedText(AnnotatedString(""), OffsetMapping.Identity) + } + val formatAmount = DecimalFormat("#,###").format(amount.toBigDecimal()) + + return TransformedText( + text = AnnotatedString(formatAmount + postfix), + offsetMapping = object : OffsetMapping{ + override fun originalToTransformed(offset: Int): Int { + if (offset <= 1) return offset + + val entireCommaCount = if (amount.length % 3 == 0) amount.length / 3 - 1 else amount.length / 3 + val sliceUntil = if (offset + entireCommaCount <= formatAmount.length) offset + entireCommaCount else formatAmount.length + val commaBeforeOffsetCount = formatAmount.substring(0 until sliceUntil).count { it == ',' } + + return offset + commaBeforeOffsetCount + } + + override fun transformedToOriginal(offset: Int): Int { + return when (offset) { + in 0..1 -> offset + in 2 until formatAmount.length -> { + val entireCommaCount = if (amount.length % 3 == 0) amount.length / 3 - 1 else amount.length / 3 + val sliceUntil = if (offset + entireCommaCount <= formatAmount.length) offset + entireCommaCount else formatAmount.length + val commaBeforeOffsetCount = formatAmount.substring(0 until sliceUntil).count { it == ',' } + offset - commaBeforeOffsetCount + } + + else -> amount.length + } + } + + } + ) + } +} diff --git a/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/textfield/UlbanBasicTextField.kt b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/textfield/UlbanBasicTextField.kt new file mode 100644 index 00000000..b5610990 --- /dev/null +++ b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/textfield/UlbanBasicTextField.kt @@ -0,0 +1,76 @@ +package com.sixkids.designsystem.component.textfield + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.text.TextStyle +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.sixkids.designsystem.theme.Gray +import com.sixkids.designsystem.theme.UlbanTheme +import com.sixkids.designsystem.theme.UlbanTypography + + +@Composable +fun UlbanBasicTextField( + modifier: Modifier = Modifier, + text: String = "", + maxLines: Int = 1, + minLines: Int = 1, + onTextChange: (String) -> Unit = {}, + hint: String = "", + textStyle: TextStyle = UlbanTypography.bodyLarge, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + visualTransformation: VisualTransformation = VisualTransformation.None, +) { + BasicTextField( + modifier = modifier, + value = text, + onValueChange = onTextChange, + textStyle = textStyle, + maxLines = maxLines, + minLines = minLines, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + visualTransformation = visualTransformation, + ) { innerTextField -> + Box( + modifier = Modifier + .padding(8.dp) + ) { + if (text.isEmpty()) { + Text( + text = hint, + style = textStyle.copy(color = Gray), + ) + } + innerTextField() + } + } +} + + +@Preview +@Composable +fun UlbanBasicTextFieldPreview() { + UlbanTheme { + var text by remember { mutableStateOf("") } + UlbanBasicTextField( + text = text, + onTextChange = { text = it }, + hint = "Hint", + ) + } +} diff --git a/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/textfield/UlbanNumberTextField.kt b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/textfield/UlbanNumberTextField.kt new file mode 100644 index 00000000..f650b4f4 --- /dev/null +++ b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/textfield/UlbanNumberTextField.kt @@ -0,0 +1,60 @@ +package com.sixkids.designsystem.component.textfield + +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +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.text.TextStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import com.sixkids.designsystem.theme.UlbanTheme +import com.sixkids.designsystem.theme.UlbanTypography + + +@Composable +fun UlbanNumberTextField( + modifier: Modifier = Modifier, + text: String = "", + maxLines: Int = 1, + minLines: Int = 1, + onTextChange: (String) -> Unit = {}, + hint: String = "", + textStyle: TextStyle = UlbanTypography.bodyLarge, + keyboardActions: KeyboardActions = KeyboardActions.Default, + postfix: String = "", +) { + UlbanBasicTextField( + modifier = modifier, + text = text, + hint = hint, + textStyle = textStyle, + onTextChange = onTextChange, + maxLines = maxLines, + minLines = minLines, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done + ), + keyboardActions = keyboardActions, + visualTransformation = NumberVisualTransformation(postfix), + ) +} + + +@Preview +@Composable +fun UlbanPointTextFieldPreview() { + UlbanTheme { + var price by remember { mutableStateOf("") } + UlbanNumberTextField( + text = price, + onTextChange = { price = it }, + hint = "Hint", + ) + } +} diff --git a/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/textfield/UlbanUnderLineIconInputField.kt b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/textfield/UlbanUnderLineIconInputField.kt new file mode 100644 index 00000000..9d2ee3b7 --- /dev/null +++ b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/textfield/UlbanUnderLineIconInputField.kt @@ -0,0 +1,85 @@ +package com.sixkids.designsystem.component.textfield + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Divider +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.sixkids.designsystem.R +import com.sixkids.designsystem.theme.Blue +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.ui.util.formatToDayMonthYear +import java.time.LocalDate + +@Composable +fun UlbanUnderLineIconInputField( + modifier: Modifier = Modifier, + text: String = "", + onIconClick: () -> Unit = {}, + @DrawableRes iconResource: Int, + textStyle: TextStyle = UlbanTypography.bodyMedium +) { + Column( + modifier = modifier + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = text, + style = textStyle, + modifier = modifier + .weight(1f) + .padding(8.dp) + ) + Icon( + painter = painterResource(iconResource), + contentDescription = null, + modifier = Modifier + .clickable { onIconClick() } + .padding(horizontal = 8.dp) + .size(24.dp) + ) + } + Divider( + modifier = Modifier.fillMaxWidth(), + color = Blue, + thickness = 2.dp + ) + } +} + +@Preview(showBackground = true) +@Composable +fun UlbanUnderLineDateFieldPreview() { + val date by remember { mutableStateOf(LocalDate.now()) } + + Column { + Text( + text = "날짜를 선택해 주세요", + style = UlbanTypography.titleSmall, + modifier = Modifier.padding(8.dp) + ) + UlbanUnderLineIconInputField( + text = date.formatToDayMonthYear(), + iconResource = R.drawable.ic_calendar, + ) + + } +} diff --git a/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/textfield/UlbanUnderLineTextField.kt b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/textfield/UlbanUnderLineTextField.kt new file mode 100644 index 00000000..5acd589c --- /dev/null +++ b/android/core/designsystem/src/main/java/com/sixkids/designsystem/component/textfield/UlbanUnderLineTextField.kt @@ -0,0 +1,189 @@ +package com.sixkids.designsystem.component.textfield + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Divider +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.sixkids.designsystem.R +import com.sixkids.designsystem.theme.Blue +import com.sixkids.designsystem.theme.UlbanTypography + +@Composable +fun UlbanUnderLineTextField( + modifier: Modifier = Modifier, + text: String = "", + onTextChange: (String) -> Unit = {}, + hint: String = "", + onIconClick: () -> Unit = {}, + inputTextType: InputTextType = InputTextType.TEXT, + textStyle: TextStyle = UlbanTypography.bodyMedium, + keyboardActions: KeyboardActions = KeyboardActions.Default, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + visualTransformation: VisualTransformation = VisualTransformation.None, +) { + + Column( + modifier = modifier, + ) { + Row( + modifier = Modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + when (inputTextType) { + InputTextType.TEXT -> { + UlbanBasicTextField( + modifier = modifier + .weight(1f), + text = text, + onTextChange = onTextChange, + hint = hint, + textStyle = textStyle, + keyboardActions = keyboardActions, + keyboardOptions = keyboardOptions, + visualTransformation = visualTransformation + ) + } + + InputTextType.POINT -> { + UlbanNumberTextField( + modifier = Modifier + .weight(1f), + text = text, + onTextChange = onTextChange, + hint = hint, + textStyle = textStyle, + keyboardActions = keyboardActions, + postfix = stringResource(id = R.string.point_postfix) + ) + } + + InputTextType.PEOPLE -> { + UlbanNumberTextField( + modifier = Modifier + .weight(1f), + text = text, + onTextChange = onTextChange, + hint = hint, + textStyle = textStyle, + keyboardActions = keyboardActions, + postfix = stringResource(id = R.string.people_postfix) + ) + } + + InputTextType.GRADE -> { + UlbanNumberTextField( + modifier = Modifier + .weight(1f), + text = text, + onTextChange = onTextChange, + hint = hint, + textStyle = textStyle, + keyboardActions = keyboardActions, + postfix = stringResource(id = R.string.grade_postfix) + ) + } + InputTextType.CLASS -> { + UlbanNumberTextField( + modifier = Modifier + .weight(1f), + text = text, + onTextChange = onTextChange, + hint = hint, + textStyle = textStyle, + keyboardActions = keyboardActions, + postfix = stringResource(id = R.string.class_postfix) + ) + } + } + if (text.isNotEmpty()) { + Icon( + painter = painterResource(id = R.drawable.ic_cancel), + contentDescription = null, + modifier = Modifier + .clickable { onIconClick() } + .padding(horizontal = 8.dp) + .size(24.dp) + ) + } + } + Divider( + modifier = Modifier.fillMaxWidth(), + color = Blue, + thickness = 2.dp + ) + } +} + + +enum class InputTextType { + TEXT, + POINT, + PEOPLE, + GRADE, + CLASS +} + +@Preview(showBackground = true) +@Composable +fun UlbanUnderLineTextFieldPreview() { + var text by remember { mutableStateOf("4월 22일 함께 달리기") } + var point by remember { mutableStateOf("1200") } + var peopleCnt by remember { mutableStateOf("") } + Column( +// verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text(text = "제목을 입력해 주세요", style = UlbanTypography.titleSmall) + UlbanUnderLineTextField( + text = text, + onTextChange = { text = it }, + hint = "hint", + onIconClick = { + text = "" + }, + inputTextType = InputTextType.TEXT + ) + + Text(text = "점수를 입력해 주세요", style = UlbanTypography.titleSmall) + UlbanUnderLineTextField( + text = point, + onTextChange = { point = it }, + hint = "hint", + onIconClick = { + point = "" + }, + inputTextType = InputTextType.POINT + ) + + Text(text = "그룹 최소 인원을 설정해 주세요", style = UlbanTypography.titleSmall) + UlbanUnderLineTextField( + text = peopleCnt, + onTextChange = { peopleCnt = it }, + hint = "hint", + onIconClick = { + peopleCnt = "" + }, + inputTextType = InputTextType.PEOPLE + ) + } +} diff --git a/android/core/designsystem/src/main/java/com/sixkids/designsystem/theme/Color.kt b/android/core/designsystem/src/main/java/com/sixkids/designsystem/theme/Color.kt index 6c8e98c0..ed547889 100644 --- a/android/core/designsystem/src/main/java/com/sixkids/designsystem/theme/Color.kt +++ b/android/core/designsystem/src/main/java/com/sixkids/designsystem/theme/Color.kt @@ -26,4 +26,13 @@ val BlueDark = Color(0XFF152B65) val GreenDark = Color(0XFF0B4C3D) val PurpleDark = Color(0XFF52148E) +val RedText = Color(0xFF52000D) +val OrangeText = Color(0xFF492800) +val YellowText = Color(0xFF4E3401) +val PurpleText = Color(0xFF300153) +val GrayLight = Color(0XFFF1F1F1) +val BlueText = Color(0xFF010F3D) + +val LoadingBackground = Color(0xA6000000) + diff --git a/android/core/designsystem/src/main/java/com/sixkids/designsystem/theme/Type.kt b/android/core/designsystem/src/main/java/com/sixkids/designsystem/theme/Type.kt index 042b3f35..60c0f3df 100644 --- a/android/core/designsystem/src/main/java/com/sixkids/designsystem/theme/Type.kt +++ b/android/core/designsystem/src/main/java/com/sixkids/designsystem/theme/Type.kt @@ -77,7 +77,7 @@ val UlbanTypography = Typography( fontFamily = npsFont, fontWeight = FontWeight.Bold, fontSize = 20.sp, - lineHeight = 20.sp, + lineHeight = 26.sp, letterSpacing = 0.8.sp ), diff --git a/android/core/designsystem/src/main/res/drawable/announce.png b/android/core/designsystem/src/main/res/drawable/announce.png index 0ff24915..4f95a517 100644 Binary files a/android/core/designsystem/src/main/res/drawable/announce.png and b/android/core/designsystem/src/main/res/drawable/announce.png differ diff --git a/android/core/designsystem/src/main/res/drawable/board.png b/android/core/designsystem/src/main/res/drawable/board.png index a9815fce..d6a0bf9f 100644 Binary files a/android/core/designsystem/src/main/res/drawable/board.png and b/android/core/designsystem/src/main/res/drawable/board.png differ diff --git a/android/core/designsystem/src/main/res/drawable/chat.png b/android/core/designsystem/src/main/res/drawable/chat.png index c8d3d301..7d7c1057 100644 Binary files a/android/core/designsystem/src/main/res/drawable/chat.png and b/android/core/designsystem/src/main/res/drawable/chat.png differ diff --git a/android/core/designsystem/src/main/res/drawable/hifive.png b/android/core/designsystem/src/main/res/drawable/hifive.png index fc18eb74..29bc1c3b 100644 Binary files a/android/core/designsystem/src/main/res/drawable/hifive.png and b/android/core/designsystem/src/main/res/drawable/hifive.png differ diff --git a/android/core/designsystem/src/main/res/drawable/ic_arrow_back.xml b/android/core/designsystem/src/main/res/drawable/ic_arrow_back.xml new file mode 100644 index 00000000..d73d39d2 --- /dev/null +++ b/android/core/designsystem/src/main/res/drawable/ic_arrow_back.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/core/designsystem/src/main/res/drawable/ic_calendar.xml b/android/core/designsystem/src/main/res/drawable/ic_calendar.xml new file mode 100644 index 00000000..452a3c28 --- /dev/null +++ b/android/core/designsystem/src/main/res/drawable/ic_calendar.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/core/designsystem/src/main/res/drawable/ic_cancel.xml b/android/core/designsystem/src/main/res/drawable/ic_cancel.xml new file mode 100644 index 00000000..c3d36647 --- /dev/null +++ b/android/core/designsystem/src/main/res/drawable/ic_cancel.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/core/designsystem/src/main/res/drawable/ic_cancel_post.xml b/android/core/designsystem/src/main/res/drawable/ic_cancel_post.xml new file mode 100644 index 00000000..a96683a4 --- /dev/null +++ b/android/core/designsystem/src/main/res/drawable/ic_cancel_post.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/core/designsystem/src/main/res/drawable/ic_chat_bubble.xml b/android/core/designsystem/src/main/res/drawable/ic_chat_bubble.xml new file mode 100644 index 00000000..3699a89c --- /dev/null +++ b/android/core/designsystem/src/main/res/drawable/ic_chat_bubble.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/core/designsystem/src/main/res/drawable/ic_chat_bubble_outline.xml b/android/core/designsystem/src/main/res/drawable/ic_chat_bubble_outline.xml new file mode 100644 index 00000000..264adf10 --- /dev/null +++ b/android/core/designsystem/src/main/res/drawable/ic_chat_bubble_outline.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/android/core/designsystem/src/main/res/drawable/ic_close_filled.xml b/android/core/designsystem/src/main/res/drawable/ic_close_filled.xml new file mode 100644 index 00000000..e1a18e26 --- /dev/null +++ b/android/core/designsystem/src/main/res/drawable/ic_close_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/core/designsystem/src/main/res/drawable/ic_copy.xml b/android/core/designsystem/src/main/res/drawable/ic_copy.xml new file mode 100644 index 00000000..942aeb96 --- /dev/null +++ b/android/core/designsystem/src/main/res/drawable/ic_copy.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/core/designsystem/src/main/res/drawable/ic_delete.xml b/android/core/designsystem/src/main/res/drawable/ic_delete.xml new file mode 100644 index 00000000..7d8a4e7f --- /dev/null +++ b/android/core/designsystem/src/main/res/drawable/ic_delete.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/core/designsystem/src/main/res/drawable/ic_down_arrow.xml b/android/core/designsystem/src/main/res/drawable/ic_down_arrow.xml new file mode 100644 index 00000000..c72e6941 --- /dev/null +++ b/android/core/designsystem/src/main/res/drawable/ic_down_arrow.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/android/core/designsystem/src/main/res/drawable/ic_edit.xml b/android/core/designsystem/src/main/res/drawable/ic_edit.xml new file mode 100644 index 00000000..e59da99e --- /dev/null +++ b/android/core/designsystem/src/main/res/drawable/ic_edit.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/core/designsystem/src/main/res/drawable/ic_filter_alt.xml b/android/core/designsystem/src/main/res/drawable/ic_filter_alt.xml new file mode 100644 index 00000000..abe03146 --- /dev/null +++ b/android/core/designsystem/src/main/res/drawable/ic_filter_alt.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/core/designsystem/src/main/res/drawable/ic_photo_camera.xml b/android/core/designsystem/src/main/res/drawable/ic_photo_camera.xml new file mode 100644 index 00000000..7283b1a9 --- /dev/null +++ b/android/core/designsystem/src/main/res/drawable/ic_photo_camera.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/core/designsystem/src/main/res/drawable/ic_recomment.xml b/android/core/designsystem/src/main/res/drawable/ic_recomment.xml new file mode 100644 index 00000000..c5fc3e63 --- /dev/null +++ b/android/core/designsystem/src/main/res/drawable/ic_recomment.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/core/designsystem/src/main/res/drawable/ic_time.xml b/android/core/designsystem/src/main/res/drawable/ic_time.xml new file mode 100644 index 00000000..ff137565 --- /dev/null +++ b/android/core/designsystem/src/main/res/drawable/ic_time.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/core/designsystem/src/main/res/drawable/paint.png b/android/core/designsystem/src/main/res/drawable/paint.png new file mode 100644 index 00000000..5dd22d87 Binary files /dev/null and b/android/core/designsystem/src/main/res/drawable/paint.png differ diff --git a/android/core/designsystem/src/main/res/drawable/pencil.png b/android/core/designsystem/src/main/res/drawable/pencil.png new file mode 100644 index 00000000..a0410423 Binary files /dev/null and b/android/core/designsystem/src/main/res/drawable/pencil.png differ diff --git a/android/core/designsystem/src/main/res/drawable/quiz.png b/android/core/designsystem/src/main/res/drawable/quiz.png new file mode 100644 index 00000000..52dfb441 Binary files /dev/null and b/android/core/designsystem/src/main/res/drawable/quiz.png differ diff --git a/android/core/designsystem/src/main/res/drawable/relay_seuccess.png b/android/core/designsystem/src/main/res/drawable/relay_seuccess.png deleted file mode 100644 index d12befb0..00000000 Binary files a/android/core/designsystem/src/main/res/drawable/relay_seuccess.png and /dev/null differ diff --git a/android/core/designsystem/src/main/res/drawable/relay_success.png b/android/core/designsystem/src/main/res/drawable/relay_success.png new file mode 100644 index 00000000..58d9a66a Binary files /dev/null and b/android/core/designsystem/src/main/res/drawable/relay_success.png differ diff --git a/android/core/designsystem/src/main/res/drawable/relay_tag.png b/android/core/designsystem/src/main/res/drawable/relay_tag.png index 02485521..fa5e3b77 100644 Binary files a/android/core/designsystem/src/main/res/drawable/relay_tag.png and b/android/core/designsystem/src/main/res/drawable/relay_tag.png differ diff --git a/android/core/designsystem/src/main/res/drawable/setting.png b/android/core/designsystem/src/main/res/drawable/setting.png index d74ab317..b463adcd 100644 Binary files a/android/core/designsystem/src/main/res/drawable/setting.png and b/android/core/designsystem/src/main/res/drawable/setting.png differ diff --git a/android/core/designsystem/src/main/res/drawable/statistics.png b/android/core/designsystem/src/main/res/drawable/statistics.png index 218e429d..5b59ffd5 100644 Binary files a/android/core/designsystem/src/main/res/drawable/statistics.png and b/android/core/designsystem/src/main/res/drawable/statistics.png differ diff --git a/android/core/designsystem/src/main/res/raw/lodaing.json b/android/core/designsystem/src/main/res/raw/lodaing.json new file mode 100644 index 00000000..cafcccf1 --- /dev/null +++ b/android/core/designsystem/src/main/res/raw/lodaing.json @@ -0,0 +1 @@ +{"v":"4.6.8","fr":29.9700012207031,"ip":0,"op":40.0000016292334,"w":256,"h":256,"nm":"Comp 1","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Shape Layer 3","ks":{"o":{"a":0,"k":100},"r":{"a":0,"k":0},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"n":"0p667_1_0p333_0","t":20,"s":[208.6,127.969,0],"e":[208.6,88,0],"to":[0,-6.66145849227905,0],"ti":[0,-0.00520833348855,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"n":"0p667_1_0p333_0","t":30,"s":[208.6,88,0],"e":[208.6,128,0],"to":[0,0.00520833348855,0],"ti":[0,-6.66666650772095,0]},{"t":40.0000016292334}]},"a":{"a":0,"k":[-70,-0.5,0]},"s":{"a":0,"k":[75,75,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[33.75,34.5]},"p":{"a":0,"k":[0,0]},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse"},{"ty":"fl","c":{"a":0,"k":[0.9843137,0.5490196,0,1]},"o":{"a":0,"k":100},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill"},{"ty":"tr","p":{"a":0,"k":[-70.125,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"ix":1,"mn":"ADBE Vector Group"}],"ip":0,"op":300.00001221925,"st":0,"bm":0,"sr":1},{"ddd":0,"ind":2,"ty":4,"nm":"Shape Layer 2","ks":{"o":{"a":0,"k":100},"r":{"a":0,"k":0},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"n":"0p667_1_0p333_0","t":15,"s":[168.6,128,0],"e":[168.6,88,0],"to":[0,-6.66666650772095,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"n":"0p667_1_0p333_0","t":25,"s":[168.6,88,0],"e":[168.6,128,0],"to":[0,0,0],"ti":[0,-6.66666650772095,0]},{"t":35.0000014255792}]},"a":{"a":0,"k":[-70,-0.5,0]},"s":{"a":0,"k":[75,75,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[33.75,34.5]},"p":{"a":0,"k":[0,0]},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse"},{"ty":"fl","c":{"a":0,"k":[0.9921569,0.8470588,0.2078431,1]},"o":{"a":0,"k":100},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill"},{"ty":"tr","p":{"a":0,"k":[-70.125,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"ix":1,"mn":"ADBE Vector Group"}],"ip":0,"op":300.00001221925,"st":0,"bm":0,"sr":1},{"ddd":0,"ind":3,"ty":4,"nm":"Shape Layer 1","ks":{"o":{"a":0,"k":100},"r":{"a":0,"k":0},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"n":"0p667_1_0p333_0","t":10,"s":[128.594,127.969,0],"e":[128.594,88,0],"to":[0,-6.66145849227905,0],"ti":[0,-0.00520833348855,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"n":"0p667_1_0p333_0","t":20,"s":[128.594,88,0],"e":[128.594,128,0],"to":[0,0.00520833348855,0],"ti":[0,-6.66666650772095,0]},{"t":30.0000012219251}]},"a":{"a":0,"k":[-70,-0.5,0]},"s":{"a":0,"k":[75,75,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[33.75,34.5]},"p":{"a":0,"k":[0,0]},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse"},{"ty":"fl","c":{"a":0,"k":[0.2627451,0.627451,0.2784314,1]},"o":{"a":0,"k":100},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill"},{"ty":"tr","p":{"a":0,"k":[-70.125,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"ix":1,"mn":"ADBE Vector Group"}],"ip":0,"op":300.00001221925,"st":0,"bm":0,"sr":1},{"ddd":0,"ind":4,"ty":4,"nm":"Shape Layer 4","ks":{"o":{"a":0,"k":100},"r":{"a":0,"k":0},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"n":"0p667_1_0p333_0","t":5,"s":[88.6,127.969,0],"e":[88.6,88,0],"to":[0,-6.66145849227905,0],"ti":[0,-0.00520833348855,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"n":"0p667_1_0p333_0","t":15,"s":[88.6,88,0],"e":[88.6,128,0],"to":[0,0.00520833348855,0],"ti":[0,-6.66666650772095,0]},{"t":25.0000010182709}]},"a":{"a":0,"k":[-70,-0.5,0]},"s":{"a":0,"k":[75,75,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[33.75,34.5]},"p":{"a":0,"k":[0,0]},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse"},{"ty":"fl","c":{"a":0,"k":[0.1176471,0.5333334,0.8980392,1]},"o":{"a":0,"k":100},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill"},{"ty":"tr","p":{"a":0,"k":[-70.125,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"ix":1,"mn":"ADBE Vector Group"}],"ip":0,"op":300.00001221925,"st":0,"bm":0,"sr":1},{"ddd":0,"ind":5,"ty":4,"nm":"Shape Layer 5","ks":{"o":{"a":0,"k":100},"r":{"a":0,"k":0},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"n":"0p667_1_0p333_0","t":0,"s":[48.6,127.969,0],"e":[48.6,88,0],"to":[0,-6.66145849227905,0],"ti":[0,-0.00520833348855,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"n":"0p667_1_0p333_0","t":10,"s":[48.6,88,0],"e":[48.6,128,0],"to":[0,0.00520833348855,0],"ti":[0,-6.66666650772095,0]},{"t":20.0000008146167}]},"a":{"a":0,"k":[-70,-0.5,0]},"s":{"a":0,"k":[75,75,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[33.75,34.5]},"p":{"a":0,"k":[0,0]},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse"},{"ty":"fl","c":{"a":0,"k":[0.8980392,0.2235294,0.2078431,1]},"o":{"a":0,"k":100},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill"},{"ty":"tr","p":{"a":0,"k":[-70.125,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"ix":1,"mn":"ADBE Vector Group"}],"ip":0,"op":300.00001221925,"st":0,"bm":0,"sr":1}]} \ No newline at end of file diff --git a/android/core/designsystem/src/main/res/values/strings.xml b/android/core/designsystem/src/main/res/values/strings.xml new file mode 100644 index 00000000..3e68bcb5 --- /dev/null +++ b/android/core/designsystem/src/main/res/values/strings.xml @@ -0,0 +1,30 @@ + + + %d명 참여 + " 점" + + " 학년" + " 반" + 확인 + 취소 + 이어 달리기 + %s 학생이 폭탄을 터트렸어요 + %d번 + 받은 질문 + 이어 달리기 전달 성공 + 이런 폭탄이 터졌어요! + 다음 친구에게 잘 전달 됐어요 + 친구에게 잘 전달 받았어요 + %d exp + 질문에 나를 지목했어요! + 어떤 질문에 나를 지목했는지\n확인할 수 있어요! + 다음 친구에게 전달하면 + 완료 + 이어 달리기 전달하기 + 이어 달리기 전달 받기 + 내가 받은 질문에 맞는 친구에게 전달 해봐요 + 친구에게 이어 달리기를 전달 받아요 + 친구와 핸드폰 뒷면을 맞대서 전달해요 + 친구와 핸드폰 뒷면을 맞대서 전달 받아요 + + diff --git a/android/core/model/src/main/java/com/sixkids/model/AcceptStatus.kt b/android/core/model/src/main/java/com/sixkids/model/AcceptStatus.kt new file mode 100644 index 00000000..43ef19ae --- /dev/null +++ b/android/core/model/src/main/java/com/sixkids/model/AcceptStatus.kt @@ -0,0 +1,7 @@ +package com.sixkids.model + +enum class AcceptStatus { + BEFORE, + APPROVE, + REFUSE +} diff --git a/android/core/model/src/main/java/com/sixkids/model/Challenge.kt b/android/core/model/src/main/java/com/sixkids/model/Challenge.kt new file mode 100644 index 00000000..23ff1ef0 --- /dev/null +++ b/android/core/model/src/main/java/com/sixkids/model/Challenge.kt @@ -0,0 +1,14 @@ +package com.sixkids.model + +import java.time.LocalDateTime + +data class Challenge( + val id: Long = 0, + val title: String = "", + val content: String = "", + val headCount: Int = 0, + val reward: Int = 0, + val startTime: LocalDateTime = LocalDateTime.now(), + val endTime: LocalDateTime = LocalDateTime.now(), + val totalCount: Int = 0 +) diff --git a/android/core/model/src/main/java/com/sixkids/model/ChallengeDetail.kt b/android/core/model/src/main/java/com/sixkids/model/ChallengeDetail.kt new file mode 100644 index 00000000..0cc138aa --- /dev/null +++ b/android/core/model/src/main/java/com/sixkids/model/ChallengeDetail.kt @@ -0,0 +1,14 @@ +package com.sixkids.model + +import java.time.LocalDateTime + +data class ChallengeDetail( + val id: Long = 0, + val title: String = "", + val content: String = "", + val startTime: LocalDateTime = LocalDateTime.now(), + val endTime: LocalDateTime = LocalDateTime.now(), + val headCount: Int = 0, + val teamCount: Int = 0, + val reportList: List = emptyList(), +) diff --git a/android/core/model/src/main/java/com/sixkids/model/ChallengeGroup.kt b/android/core/model/src/main/java/com/sixkids/model/ChallengeGroup.kt new file mode 100644 index 00000000..1f92b934 --- /dev/null +++ b/android/core/model/src/main/java/com/sixkids/model/ChallengeGroup.kt @@ -0,0 +1,6 @@ +package com.sixkids.model + +data class ChallengeGroup( + val headCount: Int, + val memberList: List, +) \ No newline at end of file diff --git a/android/core/model/src/main/java/com/sixkids/model/Chat.kt b/android/core/model/src/main/java/com/sixkids/model/Chat.kt new file mode 100644 index 00000000..d422d357 --- /dev/null +++ b/android/core/model/src/main/java/com/sixkids/model/Chat.kt @@ -0,0 +1,10 @@ +package com.sixkids.model + +data class Chat( + val memberId: Long, + val memberName: String, + val memberImageUrl: String, + val content: String, + val sendDateTime: Long, + val readCount: Int = 1 +) diff --git a/android/core/model/src/main/java/com/sixkids/model/ChatFilterWord.kt b/android/core/model/src/main/java/com/sixkids/model/ChatFilterWord.kt new file mode 100644 index 00000000..58a76a3c --- /dev/null +++ b/android/core/model/src/main/java/com/sixkids/model/ChatFilterWord.kt @@ -0,0 +1,6 @@ +package com.sixkids.model + +data class ChatFilterWord( + val id: Long, + val badWord: String, +) \ No newline at end of file diff --git a/android/core/model/src/main/java/com/sixkids/model/ChatMessage.kt b/android/core/model/src/main/java/com/sixkids/model/ChatMessage.kt new file mode 100644 index 00000000..3bdef83c --- /dev/null +++ b/android/core/model/src/main/java/com/sixkids/model/ChatMessage.kt @@ -0,0 +1,7 @@ +package com.sixkids.model + +data class ChatMessage( + val roomId: Long, + val memberImageUrl: String, + val content: String +) diff --git a/android/core/model/src/main/java/com/sixkids/model/ClassSummary.kt b/android/core/model/src/main/java/com/sixkids/model/ClassSummary.kt new file mode 100644 index 00000000..94274619 --- /dev/null +++ b/android/core/model/src/main/java/com/sixkids/model/ClassSummary.kt @@ -0,0 +1,7 @@ +package com.sixkids.model + +data class ClassSummary( + val challengeCounts: List = emptyList(), + val relayCounts: List = emptyList(), + val postsCounts: List = emptyList(), +) \ No newline at end of file diff --git a/android/core/model/src/main/java/com/sixkids/model/Comment.kt b/android/core/model/src/main/java/com/sixkids/model/Comment.kt new file mode 100644 index 00000000..dc78c45e --- /dev/null +++ b/android/core/model/src/main/java/com/sixkids/model/Comment.kt @@ -0,0 +1,21 @@ +package com.sixkids.model + +import java.time.LocalDateTime + +data class Comment( + val id: Long, + val member: MemberSimple, + val content: String, + val createTime: LocalDateTime, + val updateTime: LocalDateTime?, + val recomments: List, +) + +data class Recomment( + val id: Long, + val member: MemberSimple, + val content: String, + val createTime: LocalDateTime, + val updateTime: LocalDateTime?, + val parentId: Long, +) diff --git a/android/core/model/src/main/java/com/sixkids/model/GreetingNFC.kt b/android/core/model/src/main/java/com/sixkids/model/GreetingNFC.kt new file mode 100644 index 00000000..be1e4589 --- /dev/null +++ b/android/core/model/src/main/java/com/sixkids/model/GreetingNFC.kt @@ -0,0 +1,9 @@ +package com.sixkids.model + +import kotlinx.serialization.Serializable + +@Serializable +data class GreetingNFC( + val senderId: Int = -1, + val organizationId: Int = -1 +) diff --git a/android/core/model/src/main/java/com/sixkids/model/Group.kt b/android/core/model/src/main/java/com/sixkids/model/Group.kt new file mode 100644 index 00000000..feb8f4d2 --- /dev/null +++ b/android/core/model/src/main/java/com/sixkids/model/Group.kt @@ -0,0 +1,7 @@ +package com.sixkids.model + +data class Group( + val id: Long = 0, + val leaderId: Long = 0, + val studentList : List = emptyList(), +) diff --git a/android/core/model/src/main/java/com/sixkids/model/GroupSimple.kt b/android/core/model/src/main/java/com/sixkids/model/GroupSimple.kt new file mode 100644 index 00000000..497ab35b --- /dev/null +++ b/android/core/model/src/main/java/com/sixkids/model/GroupSimple.kt @@ -0,0 +1,7 @@ +package com.sixkids.model + +data class GroupSimple( + val headCount: Int, + val leaderId: Long, + val students: List, +) diff --git a/android/core/model/src/main/java/com/sixkids/model/GroupType.kt b/android/core/model/src/main/java/com/sixkids/model/GroupType.kt new file mode 100644 index 00000000..92e5131e --- /dev/null +++ b/android/core/model/src/main/java/com/sixkids/model/GroupType.kt @@ -0,0 +1,6 @@ +package com.sixkids.model + +enum class GroupType { + FREE, + DESIGN +} diff --git a/android/core/model/src/main/java/com/sixkids/model/JwtToken.kt b/android/core/model/src/main/java/com/sixkids/model/JwtToken.kt new file mode 100644 index 00000000..18160eaa --- /dev/null +++ b/android/core/model/src/main/java/com/sixkids/model/JwtToken.kt @@ -0,0 +1,7 @@ +package com.sixkids.model + + +data class JwtToken( + val accessToken : String, + val refreshToken : String +) diff --git a/android/core/model/src/main/java/com/sixkids/model/MatchingRoom.kt b/android/core/model/src/main/java/com/sixkids/model/MatchingRoom.kt new file mode 100644 index 00000000..4df7eda9 --- /dev/null +++ b/android/core/model/src/main/java/com/sixkids/model/MatchingRoom.kt @@ -0,0 +1,6 @@ +package com.sixkids.model + +data class MatchingRoom( + val dataKey: String, + val minCount: Int +) diff --git a/android/core/model/src/main/java/com/sixkids/model/MemberDetail.kt b/android/core/model/src/main/java/com/sixkids/model/MemberDetail.kt new file mode 100644 index 00000000..ab50602a --- /dev/null +++ b/android/core/model/src/main/java/com/sixkids/model/MemberDetail.kt @@ -0,0 +1,12 @@ +package com.sixkids.model + +data class MemberDetail( + val name: String = "", + val photo: String = "", + val isolationPoint: Double = 0.0, + val isolationRank: Int = -1, + val exp: Int = -1, + val challengeCount: Int = -1, + val relayCount: Int = -1, + val postCount: Int = -1, +) diff --git a/android/core/model/src/main/java/com/sixkids/model/MemberRankItem.kt b/android/core/model/src/main/java/com/sixkids/model/MemberRankItem.kt new file mode 100644 index 00000000..ca2ff2f1 --- /dev/null +++ b/android/core/model/src/main/java/com/sixkids/model/MemberRankItem.kt @@ -0,0 +1,7 @@ +package com.sixkids.model + +data class MemberRankItem ( + val name: String, + val exp: Int, + val rank: Int +) \ No newline at end of file diff --git a/android/core/model/src/main/java/com/sixkids/model/MemberSimple.kt b/android/core/model/src/main/java/com/sixkids/model/MemberSimple.kt new file mode 100644 index 00000000..7b005fac --- /dev/null +++ b/android/core/model/src/main/java/com/sixkids/model/MemberSimple.kt @@ -0,0 +1,10 @@ +package com.sixkids.model + +import kotlinx.serialization.Serializable + +@Serializable +data class MemberSimple( + val id: Long = 0L, + val name: String = "", + val photo: String = "", +) diff --git a/android/core/model/src/main/java/com/sixkids/model/MemberSimpleClassSummary.kt b/android/core/model/src/main/java/com/sixkids/model/MemberSimpleClassSummary.kt new file mode 100644 index 00000000..37f5a5c4 --- /dev/null +++ b/android/core/model/src/main/java/com/sixkids/model/MemberSimpleClassSummary.kt @@ -0,0 +1,6 @@ +package com.sixkids.model + +data class MemberSimpleClassSummary ( + val member: MemberSimple, + val count: Int +) \ No newline at end of file diff --git a/android/core/model/src/main/java/com/sixkids/model/MemberSimpleWithScore.kt b/android/core/model/src/main/java/com/sixkids/model/MemberSimpleWithScore.kt new file mode 100644 index 00000000..e24a057d --- /dev/null +++ b/android/core/model/src/main/java/com/sixkids/model/MemberSimpleWithScore.kt @@ -0,0 +1,6 @@ +package com.sixkids.model + +data class MemberSimpleWithScore( + val memberSimple: MemberSimple, + val relationPoint: Int +) \ No newline at end of file diff --git a/android/core/model/src/main/java/com/sixkids/model/Organization.kt b/android/core/model/src/main/java/com/sixkids/model/Organization.kt new file mode 100644 index 00000000..5f2cbb2e --- /dev/null +++ b/android/core/model/src/main/java/com/sixkids/model/Organization.kt @@ -0,0 +1,7 @@ +package com.sixkids.model + +data class Organization( + val id: Int, + val name: String, + val memberCount: Int, +) diff --git a/android/core/model/src/main/java/com/sixkids/model/Post.kt b/android/core/model/src/main/java/com/sixkids/model/Post.kt new file mode 100644 index 00000000..1ffa5327 --- /dev/null +++ b/android/core/model/src/main/java/com/sixkids/model/Post.kt @@ -0,0 +1,11 @@ +package com.sixkids.model + +import java.time.LocalDateTime + +data class Post( + val id: Long, + val title: String, + val writer: String, + val time: LocalDateTime, + val commentCount: Int +) diff --git a/android/core/model/src/main/java/com/sixkids/model/PostCategory.kt b/android/core/model/src/main/java/com/sixkids/model/PostCategory.kt new file mode 100644 index 00000000..e7df77b0 --- /dev/null +++ b/android/core/model/src/main/java/com/sixkids/model/PostCategory.kt @@ -0,0 +1,6 @@ +package com.sixkids.model + +object PostCategory { + const val FREE = "FREE" + const val NOTICE = "NOTICE" +} \ No newline at end of file diff --git a/android/core/model/src/main/java/com/sixkids/model/PostDetail.kt b/android/core/model/src/main/java/com/sixkids/model/PostDetail.kt new file mode 100644 index 00000000..171564ac --- /dev/null +++ b/android/core/model/src/main/java/com/sixkids/model/PostDetail.kt @@ -0,0 +1,12 @@ +package com.sixkids.model + +import java.time.LocalDateTime + +data class PostDetail( + val writeMember: MemberSimple = MemberSimple(0, "", ""), + val createTime: LocalDateTime = LocalDateTime.now(), + val title: String = "", + val content: String = "", + val imageUri: String = "", + val comments: List = listOf(), +) diff --git a/android/core/model/src/main/java/com/sixkids/model/Relay.kt b/android/core/model/src/main/java/com/sixkids/model/Relay.kt new file mode 100644 index 00000000..9cc5354d --- /dev/null +++ b/android/core/model/src/main/java/com/sixkids/model/Relay.kt @@ -0,0 +1,12 @@ +package com.sixkids.model + +import java.time.LocalDateTime + +data class Relay( + val id: Long = 0, + val startTime: LocalDateTime = LocalDateTime.now(), + val endTime: LocalDateTime = LocalDateTime.now(), + val lastTurn: Int = 0, + val lastMemberName: String = "", + val totalCount: Int = 0 +) diff --git a/android/core/model/src/main/java/com/sixkids/model/RelayDetail.kt b/android/core/model/src/main/java/com/sixkids/model/RelayDetail.kt new file mode 100644 index 00000000..98c8ca5b --- /dev/null +++ b/android/core/model/src/main/java/com/sixkids/model/RelayDetail.kt @@ -0,0 +1,13 @@ +package com.sixkids.model + +import java.time.LocalDateTime + +data class RelayDetail( + val id: Long = 0, + val startTime: LocalDateTime = LocalDateTime.now(), + val endTime: LocalDateTime = LocalDateTime.now(), + val lastTurn: Int = 0, + val lastMemberName: String = "", + val runnerList: List = emptyList(), + +) diff --git a/android/core/model/src/main/java/com/sixkids/model/RelayQuestion.kt b/android/core/model/src/main/java/com/sixkids/model/RelayQuestion.kt new file mode 100644 index 00000000..a6354e0b --- /dev/null +++ b/android/core/model/src/main/java/com/sixkids/model/RelayQuestion.kt @@ -0,0 +1,14 @@ +package com.sixkids.model + +import java.time.LocalDateTime + +data class RelayQuestion( + val id: Long = 0, + val memberId: Long = 0, + val memberName: String = "", + val memberPhoto: String = "", + val time: LocalDateTime = LocalDateTime.now(), + val question: String = "", + val turn: Int = 0, + val endStatus: Boolean = false, +) diff --git a/android/core/model/src/main/java/com/sixkids/model/RelayReceive.kt b/android/core/model/src/main/java/com/sixkids/model/RelayReceive.kt new file mode 100644 index 00000000..8ea53d33 --- /dev/null +++ b/android/core/model/src/main/java/com/sixkids/model/RelayReceive.kt @@ -0,0 +1,8 @@ +package com.sixkids.model + +data class RelayReceive( + val senderName: String = "", + val question: String = "", + val lastStatus: Boolean = false, + val demerit: Int = 0, +) diff --git a/android/core/model/src/main/java/com/sixkids/model/RelaySend.kt b/android/core/model/src/main/java/com/sixkids/model/RelaySend.kt new file mode 100644 index 00000000..367f9aad --- /dev/null +++ b/android/core/model/src/main/java/com/sixkids/model/RelaySend.kt @@ -0,0 +1,6 @@ +package com.sixkids.model + +data class RelaySend( + val prevMemberName: String = "", + val prevQuestion: String = "" +) \ No newline at end of file diff --git a/android/core/model/src/main/java/com/sixkids/model/Report.kt b/android/core/model/src/main/java/com/sixkids/model/Report.kt new file mode 100644 index 00000000..dab6342e --- /dev/null +++ b/android/core/model/src/main/java/com/sixkids/model/Report.kt @@ -0,0 +1,13 @@ +package com.sixkids.model + +import java.time.LocalDateTime + +data class Report( + val id: Long = 0, + val group: Group = Group(), + val startTime: LocalDateTime = LocalDateTime.now(), + val endTime: LocalDateTime = LocalDateTime.now(), + val file : String = "", + val content: String = "", + val acceptStatus: AcceptStatus = AcceptStatus.BEFORE, +) diff --git a/android/core/model/src/main/java/com/sixkids/model/RunningChallenge.kt b/android/core/model/src/main/java/com/sixkids/model/RunningChallenge.kt new file mode 100644 index 00000000..d0ecb6dd --- /dev/null +++ b/android/core/model/src/main/java/com/sixkids/model/RunningChallenge.kt @@ -0,0 +1,14 @@ +package com.sixkids.model + +import java.time.LocalDateTime + +data class RunningChallenge( + val id: Long = 0, + val title: String = "", + val content: String = "", + val totalMemberCount: Int = 0, + val doneMemberCount: Int = 0, + val startTime: LocalDateTime = LocalDateTime.now(), + val endTime: LocalDateTime = LocalDateTime.now(), + val waitingCount: Int = 0, +) diff --git a/android/core/model/src/main/java/com/sixkids/model/RunningChallengeByStudent.kt b/android/core/model/src/main/java/com/sixkids/model/RunningChallengeByStudent.kt new file mode 100644 index 00000000..8690ce8f --- /dev/null +++ b/android/core/model/src/main/java/com/sixkids/model/RunningChallengeByStudent.kt @@ -0,0 +1,12 @@ +package com.sixkids.model + +import java.time.LocalDateTime + +data class RunningChallengeByStudent( + val challenge: Challenge, + val leaderStatus: Boolean? = null, + val memberNames: List, + val type: GroupType, + val createTime: LocalDateTime?, + val endStatus: Boolean? +) diff --git a/android/core/model/src/main/java/com/sixkids/model/RunningRelay.kt b/android/core/model/src/main/java/com/sixkids/model/RunningRelay.kt new file mode 100644 index 00000000..c78e3595 --- /dev/null +++ b/android/core/model/src/main/java/com/sixkids/model/RunningRelay.kt @@ -0,0 +1,13 @@ +package com.sixkids.model + +import java.time.LocalDateTime + +data class RunningRelay( + val id: Long = 0, + val totalMemberCount: Int = 0, // 전체 턴 횟수 (선생님만 볼 수 있음) + val doneMemberCount: Int = 0, // 현재 진행된 턴 횟수 (선생님만 볼 수 있음) + val startTime: LocalDateTime = LocalDateTime.now(), + val endTime: LocalDateTime = LocalDateTime.now(), + val curMemberNickname: String = "", + val myTurnStatus : Boolean = false +) diff --git a/android/core/model/src/main/java/com/sixkids/model/SSeData.kt b/android/core/model/src/main/java/com/sixkids/model/SSeData.kt new file mode 100644 index 00000000..6c1bbf45 --- /dev/null +++ b/android/core/model/src/main/java/com/sixkids/model/SSeData.kt @@ -0,0 +1,16 @@ +package com.sixkids.model + +import kotlinx.datetime.toKotlinLocalDateTime +import kotlinx.serialization.Serializable +import java.time.LocalDateTime +import kotlinx.datetime.LocalDateTime as KotlinLocalDateTime + +@Serializable +data class SseData( + val eventType: String, + val receiverId: Long, + val url: Long?, + val data: String?, + val time: KotlinLocalDateTime = LocalDateTime.now().toKotlinLocalDateTime() + +) diff --git a/android/core/model/src/main/java/com/sixkids/model/SseEventType.kt b/android/core/model/src/main/java/com/sixkids/model/SseEventType.kt new file mode 100644 index 00000000..453160fd --- /dev/null +++ b/android/core/model/src/main/java/com/sixkids/model/SseEventType.kt @@ -0,0 +1,9 @@ +package com.sixkids.model + +enum class SseEventType { + SSE_CONNECT, + INVITE_REQUEST, + INVITE_RESPONSE, + KICK_MEMBER, + CREATE_GROUP, +} diff --git a/android/core/model/src/main/java/com/sixkids/model/StudentHomeInfo.kt b/android/core/model/src/main/java/com/sixkids/model/StudentHomeInfo.kt new file mode 100644 index 00000000..96740a4e --- /dev/null +++ b/android/core/model/src/main/java/com/sixkids/model/StudentHomeInfo.kt @@ -0,0 +1,10 @@ +package com.sixkids.model + +data class StudentHomeInfo ( + val name: String, + val photo: String, + val className: String, + val exp: Int, + val notifyCount: Int, + val relations: List, +) \ No newline at end of file diff --git a/android/core/model/src/main/java/com/sixkids/model/StudentRelation.kt b/android/core/model/src/main/java/com/sixkids/model/StudentRelation.kt new file mode 100644 index 00000000..9764dad2 --- /dev/null +++ b/android/core/model/src/main/java/com/sixkids/model/StudentRelation.kt @@ -0,0 +1,11 @@ +package com.sixkids.model + +data class StudentRelation( + val id: Long = -1, + val name: String = "", + val relationPoint: Int = 0, + val tagGreetingCount: Int = 0, + val groupCount: Int = 0, + val receiveCount: Int = 0, + val sendCount: Int = 0, +) diff --git a/android/core/model/src/main/java/com/sixkids/model/UserInfo.kt b/android/core/model/src/main/java/com/sixkids/model/UserInfo.kt new file mode 100644 index 00000000..bec59f95 --- /dev/null +++ b/android/core/model/src/main/java/com/sixkids/model/UserInfo.kt @@ -0,0 +1,9 @@ +package com.sixkids.model + +data class UserInfo( + val id: Int, + val name: String, + val email: String, + val photo: String, + val role: String +) diff --git a/android/core/nfc/.gitignore b/android/core/nfc/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/android/core/nfc/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/android/core/nfc/build.gradle.kts b/android/core/nfc/build.gradle.kts new file mode 100644 index 00000000..60961426 --- /dev/null +++ b/android/core/nfc/build.gradle.kts @@ -0,0 +1,11 @@ +plugins { + alias(libs.plugins.sixkids.android.library) +} + +android { + namespace = "com.sixkids.core.nfc" + +} + +dependencies { +} diff --git a/android/core/nfc/consumer-rules.pro b/android/core/nfc/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/android/core/nfc/proguard-rules.pro b/android/core/nfc/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/android/core/nfc/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/android/core/nfc/src/main/AndroidManifest.xml b/android/core/nfc/src/main/AndroidManifest.xml new file mode 100644 index 00000000..ae4c1e29 --- /dev/null +++ b/android/core/nfc/src/main/AndroidManifest.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + diff --git a/android/core/nfc/src/main/kotlin/com/sixkids/core/nfc/HCEService.kt b/android/core/nfc/src/main/kotlin/com/sixkids/core/nfc/HCEService.kt new file mode 100644 index 00000000..c0726faf --- /dev/null +++ b/android/core/nfc/src/main/kotlin/com/sixkids/core/nfc/HCEService.kt @@ -0,0 +1,38 @@ +package com.sixkids.core.nfc + +import android.nfc.cardemulation.HostApduService +import android.os.Bundle + +class HCEService : HostApduService() { + override fun processCommandApdu(commandApdu: ByteArray?, extras: Bundle?): ByteArray { + if (commandApdu == null) { + return FAIL_SW + } + + // Check if APDU matches the SELECT APDU defined above + return if (commandApdu contentEquals SELECT_APDU) { + // Data to be sent to B screen + val newData = data.toByteArray() + setData("") + newData + SUCCESS_SW + } else { + FAIL_SW + } + } + + override fun onDeactivated(p0: Int) { + } + + companion object { + val SELECT_APDU = byteArrayOf(0x00.toByte(), 0xA4.toByte(), 0x04.toByte(), 0x00.toByte(), 0x07.toByte(), 0xF0.toByte(), 0x01.toByte(), 0x02.toByte(), 0x03.toByte(), 0x04.toByte(), 0x05.toByte(), 0x06.toByte()) + val SUCCESS_SW = byteArrayOf(0x90.toByte(), 0x00.toByte()) // Status word (SW) for success + val FAIL_SW = byteArrayOf(0x6F.toByte(), 0x00.toByte()) + + + private var data: String = "" + + fun setData(data: String) { + this.data = data + } + } +} diff --git a/android/core/nfc/src/main/res/values/strings.xml b/android/core/nfc/src/main/res/values/strings.xml new file mode 100644 index 00000000..e0da84ec --- /dev/null +++ b/android/core/nfc/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Ulban + diff --git a/android/core/nfc/src/main/res/xml/apdu_service.xml b/android/core/nfc/src/main/res/xml/apdu_service.xml new file mode 100644 index 00000000..1500f8f9 --- /dev/null +++ b/android/core/nfc/src/main/res/xml/apdu_service.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/android/core/ui/src/main/java/com/sixkids/ui/SnackbarToken.kt b/android/core/ui/src/main/java/com/sixkids/ui/SnackbarToken.kt new file mode 100644 index 00000000..7a105af6 --- /dev/null +++ b/android/core/ui/src/main/java/com/sixkids/ui/SnackbarToken.kt @@ -0,0 +1,10 @@ +package com.sixkids.ui + +import androidx.annotation.DrawableRes + +data class SnackbarToken( + val message: String = "", + @DrawableRes val actionIcon: Int? = null, + val actionButtonText: String? = null, + val onClickActionButton: () -> Unit = {}, +) diff --git a/android/core/ui/src/main/java/com/sixkids/ui/extension/Flow.kt b/android/core/ui/src/main/java/com/sixkids/ui/extension/Flow.kt new file mode 100644 index 00000000..c457486c --- /dev/null +++ b/android/core/ui/src/main/java/com/sixkids/ui/extension/Flow.kt @@ -0,0 +1,22 @@ +package com.sixkids.ui.extension + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.flow.Flow + +@Composable +inline fun Flow.collectWithLifecycle( + minActiveState: Lifecycle.State = Lifecycle.State.STARTED, + noinline action: suspend (T) -> Unit, +) { + val lifecycleOwner = LocalLifecycleOwner.current + + LaunchedEffect(this, lifecycleOwner) { + lifecycleOwner.lifecycle.repeatOnLifecycle(minActiveState) { + this@collectWithLifecycle.collect { action(it) } + } + } +} diff --git a/android/core/ui/src/main/java/com/sixkids/ui/extension/Result.kt b/android/core/ui/src/main/java/com/sixkids/ui/extension/Result.kt new file mode 100644 index 00000000..cba8c684 --- /dev/null +++ b/android/core/ui/src/main/java/com/sixkids/ui/extension/Result.kt @@ -0,0 +1,7 @@ +package com.sixkids.ui.extension + +inline fun Result.flatMap(block: (T) -> (Result)): Result { + return this.mapCatching { + block(it).getOrThrow() + } +} diff --git a/android/core/ui/src/main/java/com/sixkids/ui/util/Date.kt b/android/core/ui/src/main/java/com/sixkids/ui/util/Date.kt new file mode 100644 index 00000000..5d5fe69c --- /dev/null +++ b/android/core/ui/src/main/java/com/sixkids/ui/util/Date.kt @@ -0,0 +1,31 @@ +package com.sixkids.ui.util + +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.format.DateTimeFormatter + +fun LocalDateTime.formatToMonthDayTime(): String { + val formatter = DateTimeFormatter.ofPattern("MM.dd HH:mm") + return this.format(formatter) +} + +fun LocalDateTime.formatToMonthDayTimeKorean(): String { + val formatter = DateTimeFormatter.ofPattern("M월 d일 a h시 m분") + return this.format(formatter) +} + +fun LocalDate.formatToDayMonthYear(): String { + val formatter = DateTimeFormatter.ofPattern("MM/dd/yyyy") + return this.format(formatter) +} + +fun LocalTime.formatToHourMinute(): String { + val formatter = DateTimeFormatter.ofPattern("HH:mm") + return this.format(formatter) +} + +fun LocalDateTime.formatToMonthDayKorean(): String { + val formatter = DateTimeFormatter.ofPattern("M월 d일") + return this.format(formatter) +} \ No newline at end of file diff --git a/android/core/ui/src/main/java/com/sixkids/ui/util/Int.kt b/android/core/ui/src/main/java/com/sixkids/ui/util/Int.kt new file mode 100644 index 00000000..51e53a3b --- /dev/null +++ b/android/core/ui/src/main/java/com/sixkids/ui/util/Int.kt @@ -0,0 +1,5 @@ +package com.sixkids.ui.util + +fun Int.formatPoint(): String { + return String.format("%,d POINT", this) +} diff --git a/android/data/build.gradle.kts b/android/data/build.gradle.kts index d079fe21..4b33d17a 100644 --- a/android/data/build.gradle.kts +++ b/android/data/build.gradle.kts @@ -13,6 +13,7 @@ dependencies { implementation(libs.bundles.retrofit) implementation(libs.datastore) + implementation(libs.paging) testImplementation(libs.junit) } diff --git a/android/data/src/main/AndroidManifest.xml b/android/data/src/main/AndroidManifest.xml index 8bdb7e14..891017a8 100644 --- a/android/data/src/main/AndroidManifest.xml +++ b/android/data/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ + diff --git a/android/data/src/main/java/com/sixkids/data/api/ChallengeService.kt b/android/data/src/main/java/com/sixkids/data/api/ChallengeService.kt new file mode 100644 index 00000000..ebf683a3 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/api/ChallengeService.kt @@ -0,0 +1,60 @@ +package com.sixkids.data.api + +import com.sixkids.data.model.request.ChallengeCreateRequest +import com.sixkids.data.model.response.ChallengeDetailResponse +import com.sixkids.data.model.response.ChallengeHistoryResponse +import com.sixkids.data.model.response.ChallengeSimpleResponse +import com.sixkids.data.model.response.RunningChallengeByStudentResponse +import com.sixkids.data.model.response.RunningChallengeResponse +import com.sixkids.data.network.ApiResponse +import com.sixkids.data.network.ApiResult +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.PATCH +import retrofit2.http.POST +import retrofit2.http.Path +import retrofit2.http.Query + +interface ChallengeService { + + + @POST("challenges") + suspend fun createChallenge( + @Body challengeCreateRequest: ChallengeCreateRequest + ): ApiResult> + + @GET("challenges") + suspend fun getChallengeHistory( + @Query("orgId") organizationId: Int, + @Query("memberId") memberId: Int? = null, + @Query("page") page: Int, + @Query("size") size: Int, + ): ApiResult> + + @GET("challenges/running") + suspend fun getRunningChallenge( + @Query("organizationId") organizationId: Int, + ): ApiResult> + + @GET("challenges/{id}/simple") + suspend fun getChallengeSimple( + @Path("id") challengeId: Int + ): ApiResult> + + @GET("challenges/{id}") + suspend fun getChallengeDetail( + @Path("id") challengeId: Long, + @Query("groupId") memberId: Long?, + ): ApiResult> + + @PATCH("challenges/reports/{id}") + suspend fun gradingChallenge( + @Path("id") reportId: Long, + @Query("reportType") acceptStatus: String, + ): ApiResult> + + @GET("challenges/running/member") + suspend fun getRunningChallengeByStudent( + @Query("organizationId") organizationId: Int, + ): ApiResult> +} diff --git a/android/data/src/main/java/com/sixkids/data/api/ChatFilterService.kt b/android/data/src/main/java/com/sixkids/data/api/ChatFilterService.kt new file mode 100644 index 00000000..f51760d7 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/api/ChatFilterService.kt @@ -0,0 +1,41 @@ +package com.sixkids.data.api + +import com.sixkids.data.model.request.ChatFilterRequest +import com.sixkids.data.model.response.ChattingFilterListResponse +import com.sixkids.data.model.response.ChattingFilterResponse +import com.sixkids.data.network.ApiResponse +import com.sixkids.data.network.ApiResult +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.PATCH +import retrofit2.http.POST +import retrofit2.http.Path +import retrofit2.http.Query + +interface ChatFilterService { + @GET("filters") + suspend fun getChatFilters( + @Query("organizationId") organizationId: Int, + @Query("page") page: Int, + @Query("size") size: Int, + ): ApiResult> + + @DELETE("filters/{id}") + suspend fun deleteChatFilter( + @Path("id") id: Long, + ): ApiResult> + + @POST("filters/{organizationId}") + suspend fun createChatFilter( + @Path("organizationId") organizationId: Long, + @Body badWord: ChatFilterRequest, + ): ApiResult> + + @PATCH("filters/{organizationId}/{id}") + suspend fun updateChatFilter( + @Path("organizationId") organizationId: Long, + @Path("id") id: Long, + @Body badWord: ChatFilterRequest, + ): ApiResult> +} \ No newline at end of file diff --git a/android/data/src/main/java/com/sixkids/data/api/ChattingService.kt b/android/data/src/main/java/com/sixkids/data/api/ChattingService.kt new file mode 100644 index 00000000..b6998686 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/api/ChattingService.kt @@ -0,0 +1,16 @@ +package com.sixkids.data.api + +import com.sixkids.data.model.response.ChatHistoryResponse +import com.sixkids.data.network.ApiResponse +import com.sixkids.data.network.ApiResult +import retrofit2.http.GET +import retrofit2.http.Query + +interface ChattingService { + @GET("chats") + suspend fun getChatList( + @Query("roomId") roomId: Long, + @Query("page") page: Int, + @Query("size") size: Int + ): ApiResult> +} \ No newline at end of file diff --git a/android/data/src/main/java/com/sixkids/data/api/CommentService.kt b/android/data/src/main/java/com/sixkids/data/api/CommentService.kt new file mode 100644 index 00000000..4f786b86 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/api/CommentService.kt @@ -0,0 +1,34 @@ +package com.sixkids.data.api + +import com.sixkids.data.model.request.NewCommentRequest +import com.sixkids.data.model.request.UpdateCommentRequest +import com.sixkids.data.network.ApiResponse +import com.sixkids.data.network.ApiResult +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.PATCH +import retrofit2.http.POST +import retrofit2.http.Path + +interface CommentService { + @POST("comments") + suspend fun createComment( + @Body request: NewCommentRequest + ): ApiResult> + + @DELETE("comments/{id}") + suspend fun deleteComment( + @Path("id") id: Long + ): ApiResult> + + @PATCH("comments/{id}") + suspend fun updateComment( + @Path("id") id: Long, + @Body request: UpdateCommentRequest + ): ApiResult> + + @POST("comments/{id}/report") + suspend fun reportComment( + @Path("id") id: Long + ): ApiResult> +} \ No newline at end of file diff --git a/android/data/src/main/java/com/sixkids/data/api/GroupService.kt b/android/data/src/main/java/com/sixkids/data/api/GroupService.kt new file mode 100644 index 00000000..6444911b --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/api/GroupService.kt @@ -0,0 +1,48 @@ +package com.sixkids.data.api + +import com.sixkids.data.model.response.GroupMatchingRoomResponse +import com.sixkids.data.model.response.GroupMatchingSuccessResponse +import com.sixkids.data.network.ApiResponse +import com.sixkids.data.network.ApiResult +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Query + +interface GroupService { + @POST("challenges/groups/matchingroom") + suspend fun createMatchingRoom( + @Query("challengeId") challengeId: Long, + ): ApiResult> + + @GET("challenges/groups/invite") + suspend fun inviteFriend( + @Query("key") key: String, + @Query("memberId") memberId: Long, + ): ApiResult> + + @DELETE("challenges/groups/matching") + suspend fun deportFriend( + @Query("key") key: String, + @Query("memberId") memberId: Long, + ): ApiResult> + + @POST("challenges/groups/join") + suspend fun joinGroup( + @Query("key") key: String, + @Query("joinStatus") joinStatus : Boolean, + ): ApiResult> + + @POST("challenges/groups") + suspend fun createGroup( + @Query("key") key: String, + ): ApiResult> + + @GET("challenges/groups/matching") + suspend fun getMatchingGroup( + @Query("organizationId") organizationId: Long, + @Query("minCount") minCount: Int, + @Query("matchingType") matchingType: String, + @Query("members") members: List, + ): ApiResult>> +} diff --git a/android/data/src/main/java/com/sixkids/data/api/MemberOrgService.kt b/android/data/src/main/java/com/sixkids/data/api/MemberOrgService.kt new file mode 100644 index 00000000..956bdd6c --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/api/MemberOrgService.kt @@ -0,0 +1,47 @@ +package com.sixkids.data.api + +import com.sixkids.data.model.request.GreetingRequest +import com.sixkids.data.model.response.StudentDetailResponse +import com.sixkids.data.model.response.StudentHomeResponse +import com.sixkids.data.model.response.StudentRelationDetailResponse +import com.sixkids.data.model.response.StudentWithRelationScoreResponse +import com.sixkids.data.network.ApiResponse +import com.sixkids.data.network.ApiResult +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Path +import retrofit2.http.Query + +interface MemberOrgService { + @GET("organizations/{id}/home") + suspend fun getStudentHomeInfo( + @Path("id") organizationId: Long + ): ApiResult> + + @GET("organizations/{id}") + suspend fun getMemberDetail( + @Path("id") organizationId: Long, + @Query("memberId") memberId: Long + ): ApiResult> + + @GET("organizations/{id}/relations") + suspend fun getRelationSimple( + @Path("id") organizationId: Long, + @Query("memberId") memberId: Int, + @Query("limit") relationType: Int? = null + ): ApiResult>> + + @GET("organizations/{id}/relation") + suspend fun getRelationDetail( + @Path("id") organizationId: Long, + @Query("sourceMemberId") sourceMemberId: Int, + @Query("targetMemberId") targetMemberId: Int + ): ApiResult> + + @POST("organizations/tag") + suspend fun tagGreeting( + @Body greetingRequest: GreetingRequest + ): ApiResult> + +} \ No newline at end of file diff --git a/android/data/src/main/java/com/sixkids/data/api/MemberService.kt b/android/data/src/main/java/com/sixkids/data/api/MemberService.kt new file mode 100644 index 00000000..9558279a --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/api/MemberService.kt @@ -0,0 +1,44 @@ +package com.sixkids.data.api + +import com.sixkids.data.model.request.FcmRequest +import com.sixkids.data.model.response.ApiResponse +import com.sixkids.data.model.response.MemberInfoResponse +import com.sixkids.data.model.response.MemberSimpleInfoResponse +import com.sixkids.data.model.response.SignInResponse +import com.sixkids.data.model.response.UpdateProfilePhotoResponse +import com.sixkids.data.network.ApiResult +import okhttp3.MultipartBody +import okhttp3.RequestBody +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.Multipart +import retrofit2.http.PATCH +import retrofit2.http.POST +import retrofit2.http.Part +import retrofit2.http.PartMap +import retrofit2.http.Path + +interface MemberService { + @GET("members/") + suspend fun getMemberInfo(): ApiResult> + + @GET("members/{id}") + suspend fun getMemberInfoById( + @Path("id") id: Long + ): ApiResult> + + @Multipart + @PATCH("members/photo") + suspend fun updateMemberProfilePhoto( + @Part file: MultipartBody.Part?, + @PartMap data: HashMap + ): ApiResult> + + @PATCH("members/token") + suspend fun autoSignIn() : ApiResult> + + @POST("members/fcm") + suspend fun updateFCMToken( + @Body fcmToken: FcmRequest + ): ApiResult> +} diff --git a/android/data/src/main/java/com/sixkids/data/api/OrganizationService.kt b/android/data/src/main/java/com/sixkids/data/api/OrganizationService.kt new file mode 100644 index 00000000..dd814144 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/api/OrganizationService.kt @@ -0,0 +1,57 @@ +package com.sixkids.data.api + +import com.sixkids.data.model.request.JoinOrganizationRequest +import com.sixkids.data.model.request.NewOrganizationRequest +import com.sixkids.data.model.response.ApiResponse +import com.sixkids.data.model.response.ClassSummaryResponse +import com.sixkids.data.model.response.OrganizationInviteCodeResponse +import com.sixkids.data.model.response.OrganizationNameResponse +import com.sixkids.data.model.response.OrganizationMemberResponse +import com.sixkids.data.model.response.OrganizationResponse +import com.sixkids.data.model.response.RankResponse +import com.sixkids.data.network.ApiResult +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.PATCH +import retrofit2.http.POST +import retrofit2.http.Path + +interface OrganizationService { + @GET("organizations") + suspend fun getOrganizationList(): ApiResult>> + + @POST("organizations") + suspend fun newOrganization(@Body newOrganizationRequest: NewOrganizationRequest): ApiResult> + + @POST("organizations/{id}/join") + suspend fun joinOrganization( + @Path(value = "id") orgId: Int, + @Body joinOrganizationRequest: JoinOrganizationRequest + ): ApiResult> + + @GET("organizations/{id}/summary") + suspend fun getOrganizationSummary( + @Path(value = "id") organizationId: Int + ): ApiResult> + + @PATCH("organizations/{id}") + suspend fun updateOrganization( + @Path(value = "id") organizationId: Int, + @Body newOrganizationRequest: NewOrganizationRequest + ): ApiResult> + + @GET("organizations/{id}/code") + suspend fun getOrganizationInviteCode( + @Path(value = "id") organizationId: Int + ): ApiResult> + + @GET("organizations/{id}/members") + suspend fun getOrganizationMembers( + @Path(value = "id") orgId: Int + ): ApiResult>> + + @GET("organizations/{id}/rank") + suspend fun getOrganizationRank( + @Path(value = "id") orgId: Int + ): ApiResult>> +} \ No newline at end of file diff --git a/android/data/src/main/java/com/sixkids/data/api/PostService.kt b/android/data/src/main/java/com/sixkids/data/api/PostService.kt new file mode 100644 index 00000000..d6248997 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/api/PostService.kt @@ -0,0 +1,59 @@ +package com.sixkids.data.api + +import com.sixkids.data.model.request.NewPostRequest +import com.sixkids.data.model.response.PostDetailResponse +import com.sixkids.data.model.response.PostListResponse +import com.sixkids.data.network.ApiResponse +import com.sixkids.data.network.ApiResult +import okhttp3.MultipartBody +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.Multipart +import retrofit2.http.PATCH +import retrofit2.http.POST +import retrofit2.http.Part +import retrofit2.http.Path +import retrofit2.http.Query + +interface PostService { + + @GET("boards") + suspend fun getPosts( + @Query("organizationId") organizationId: Int, + @Query("memberId") memberId: Int? = null, + @Query("page") page: Int, + @Query("size") size: Int, + @Query("postCategory") postCategory: String, + ): ApiResult> + + @Multipart + @POST("boards") + suspend fun createPost( + @Query("organizationId") organizationId: Long, + @Part("request") postRequestBody: NewPostRequest, + @Part file: MultipartBody.Part?, + ): ApiResult> + + @GET("boards/{id}") + suspend fun getPostDetail( + @Path("id") postId: Long, + ): ApiResult> + + @DELETE("boards/{id}") + suspend fun deletePost( + @Path("id") postId: Long, + ): ApiResult> + + @Multipart + @PATCH("boards/{id}") + suspend fun updatePost( + @Path("id") postId: Long, + @Part("request") postRequestBody: NewPostRequest, + @Part file: MultipartBody.Part?, + ): ApiResult> + + @POST("boards/{id}/report") + suspend fun reportPost( + @Path("id") postId: Long, + ): ApiResult> +} \ No newline at end of file diff --git a/android/data/src/main/java/com/sixkids/data/api/RelayService.kt b/android/data/src/main/java/com/sixkids/data/api/RelayService.kt new file mode 100644 index 00000000..5f0b7791 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/api/RelayService.kt @@ -0,0 +1,59 @@ +package com.sixkids.data.api + +import com.sixkids.data.model.request.ReceiveRelayRequest +import com.sixkids.data.model.request.RelayCreateRequest +import com.sixkids.data.model.response.ReceiveRelayResponse +import com.sixkids.data.model.response.RelayDetailResponse +import com.sixkids.data.model.response.RelayHistoryResponse +import com.sixkids.data.model.response.RunningRelayResponse +import com.sixkids.data.model.response.SendRelayResponse +import com.sixkids.data.network.ApiResponse +import com.sixkids.data.network.ApiResult +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Path +import retrofit2.http.Query + +interface RelayService { + + @GET("relays") + suspend fun getRelayHistory( + @Query("orgId") organizationId: Int, + @Query("memberId") memberId: Int? = null, + @Query("page") page: Int, + @Query("size") size: Int, + ): ApiResult> + + @GET("relays/running") + suspend fun getRunningRelay( + @Query("organizationId") organizationId: Long + ): ApiResult> + + @GET("relays/{id}") + suspend fun getRelayDetail( + @Path("id") relayId: Long + ): ApiResult> + + @POST("relays") + suspend fun createRelay( + @Body relayCreateRequest: RelayCreateRequest + ): ApiResult> + + @GET("relays/{id}/question") + suspend fun getRelayQuestion( + @Path("id") relayId: Long + ): ApiResult> + + @POST("relays/{id}/receive") + suspend fun receiveRelay( + @Path("id") relayId: Int, + @Body receiveRelayRequest: ReceiveRelayRequest + ): ApiResult> + + @POST("relays/{id}/send") + suspend fun sendRelay( + @Path("id") relayId: Int, + ): ApiResult> + +} \ No newline at end of file diff --git a/android/data/src/main/java/com/sixkids/data/api/SignInService.kt b/android/data/src/main/java/com/sixkids/data/api/SignInService.kt new file mode 100644 index 00000000..305ac0dd --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/api/SignInService.kt @@ -0,0 +1,28 @@ +package com.sixkids.data.api + +import com.sixkids.data.model.request.SignInRequest +import com.sixkids.data.model.response.ApiResponse +import com.sixkids.data.model.response.SignInResponse +import com.sixkids.data.network.ApiResult +import okhttp3.MultipartBody +import okhttp3.RequestBody +import retrofit2.http.Body +import retrofit2.http.Multipart +import retrofit2.http.POST +import retrofit2.http.Part +import retrofit2.http.PartMap + +interface SignInService { + + @POST("members/sign-in") + suspend fun signIn( + @Body signInRequest: SignInRequest + ): ApiResult> + + @Multipart + @POST("members/") + suspend fun signUp( + @Part file: MultipartBody.Part?, + @PartMap data: HashMap + ): ApiResult> +} \ No newline at end of file diff --git a/android/data/src/main/java/com/sixkids/data/api/TokenService.kt b/android/data/src/main/java/com/sixkids/data/api/TokenService.kt index 56a4497d..db9e0095 100644 --- a/android/data/src/main/java/com/sixkids/data/api/TokenService.kt +++ b/android/data/src/main/java/com/sixkids/data/api/TokenService.kt @@ -1,14 +1,11 @@ package com.sixkids.data.api -import com.sixkids.data.model.request.RefreshTokenRequest +import com.sixkids.data.model.response.ApiResponse import com.sixkids.data.model.response.TokenResponse import com.sixkids.data.network.ApiResult -import retrofit2.http.Body import retrofit2.http.POST interface TokenService { @POST("members/token") - suspend fun refreshToken( - @Body refreshTokenRequest: RefreshTokenRequest - ): ApiResult + suspend fun refreshToken(): ApiResult> } \ No newline at end of file diff --git a/android/data/src/main/java/com/sixkids/data/di/DataSourceModule.kt b/android/data/src/main/java/com/sixkids/data/di/DataSourceModule.kt new file mode 100644 index 00000000..24464180 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/di/DataSourceModule.kt @@ -0,0 +1,87 @@ +package com.sixkids.data.di + +import com.sixkids.data.repository.challenge.remote.ChallengeRemoteDataSource +import com.sixkids.data.repository.challenge.remote.ChallengeRemoteDataSourceImpl +import com.sixkids.data.repository.chatting.remote.ChattingRemoteDataSource +import com.sixkids.data.repository.chatting.remote.ChattingRemoteDataSourceImpl +import com.sixkids.data.repository.chattingfilter.remote.ChattingFilterRemoteDataSource +import com.sixkids.data.repository.chattingfilter.remote.ChattingFilterRemoteDataSourceImpl +import com.sixkids.data.repository.comment.remote.CommentRemoteDataSource +import com.sixkids.data.repository.comment.remote.CommentRemoteDataSourceImpl +import com.sixkids.data.repository.group.remote.GroupDataSource +import com.sixkids.data.repository.group.remote.GroupDataSourceImpl +import com.sixkids.data.repository.organization.local.OrganizationLocalDataSource +import com.sixkids.data.repository.organization.local.OrganizationLocalDataSourceImpl +import com.sixkids.data.repository.organization.remote.OrganizationRemoteDataSource +import com.sixkids.data.repository.organization.remote.OrganizationRemoteDataSourceImpl +import com.sixkids.data.repository.post.remote.PostRemoteDataSource +import com.sixkids.data.repository.post.remote.PostRemoteDataSourceImpl +import com.sixkids.data.repository.relay.remote.RelayRemoteDataSource +import com.sixkids.data.repository.relay.remote.RelayRemoteDataSourceImpl +import com.sixkids.data.repository.user.local.UserLocalDataSource +import com.sixkids.data.repository.user.local.UserLocalDataSourceImpl +import com.sixkids.data.repository.user.remote.UserRemoteDataSource +import com.sixkids.data.repository.user.remote.UserRemoteDataSourceImpl +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +abstract class DataSourceModule { + @Binds + abstract fun bindUserDataSource( + userDataSource: UserRemoteDataSourceImpl + ): UserRemoteDataSource + + @Binds + abstract fun bindLocalUserDataSource( + userLocalDataSource: UserLocalDataSourceImpl + ): UserLocalDataSource + + @Binds + abstract fun bindChallengeDataSource( + challengeRemoteDataSource: ChallengeRemoteDataSourceImpl + ): ChallengeRemoteDataSource + + @Binds + abstract fun bindOrganizationRemoteDataSource( + organizationRemoteDataSource: OrganizationRemoteDataSourceImpl + ): OrganizationRemoteDataSource + + @Binds + abstract fun bindLocalOrganizationDataSource( + organizationLocalDataSource: OrganizationLocalDataSourceImpl + ): OrganizationLocalDataSource + + @Binds + abstract fun bindPostRemoteDataSource( + postRemoteDataSource: PostRemoteDataSourceImpl + ): PostRemoteDataSource + + @Binds + abstract fun bindCommentRemoteDataSource( + commentRemoteDataSource: CommentRemoteDataSourceImpl + ): CommentRemoteDataSource + + @Binds + abstract fun bindChattingRemoteDataSource( + chattingRemoteDataSource: ChattingRemoteDataSourceImpl + ): ChattingRemoteDataSource + + @Binds + abstract fun bindRelayRemoteDataSource( + relayRemoteDataSource: RelayRemoteDataSourceImpl + ): RelayRemoteDataSource + + @Binds + abstract fun bindChattingFilterRemoteDataSource( + chattingFilterRemoteDataSource: ChattingFilterRemoteDataSourceImpl + ): ChattingFilterRemoteDataSource + + @Binds + abstract fun bindGroupDataSource( + groupDataSource: GroupDataSourceImpl + ): GroupDataSource +} diff --git a/android/data/src/main/java/com/sixkids/data/di/DataStoreModule.kt b/android/data/src/main/java/com/sixkids/data/di/DataStoreModule.kt index 89afd9d6..db319b9b 100644 --- a/android/data/src/main/java/com/sixkids/data/di/DataStoreModule.kt +++ b/android/data/src/main/java/com/sixkids/data/di/DataStoreModule.kt @@ -18,8 +18,9 @@ object DataStoreModule { private const val USER_PREFERENCES = "user_preferences" - @Provides + @Singleton + @Provides fun provideUserPreferenceDataStore(@ApplicationContext context: Context) : DataStore { return PreferenceDataStoreFactory.create( diff --git a/android/data/src/main/java/com/sixkids/data/di/NetworkModule.kt b/android/data/src/main/java/com/sixkids/data/di/NetworkModule.kt index 96e426e5..21e2704b 100644 --- a/android/data/src/main/java/com/sixkids/data/di/NetworkModule.kt +++ b/android/data/src/main/java/com/sixkids/data/di/NetworkModule.kt @@ -4,7 +4,11 @@ import com.sixkids.data.network.ApiResultCallAdapterFactory import com.sixkids.data.network.RefreshTokenInterceptor import com.sixkids.data.network.TokenAuthenticator import com.sixkids.data.network.TokenInterceptor +import com.sixkids.data.util.LocalDateAdapter +import com.sixkids.data.util.LocalDateTimeAdapter +import com.sixkids.data.util.UnitJsonAdapter import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -21,7 +25,8 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) object NetworkModule { - private const val BASE_URL = "base_url" + private const val BASE_URL = "https://k10d107.p.ssafy.io/api/" + @Qualifier @Retention(AnnotationRetention.BINARY) annotation class PublicOkHttpClient @@ -49,9 +54,18 @@ object NetworkModule { @Provides @Singleton - fun moshi(): Moshi { - return Moshi.Builder().build() - } + fun moshi(): Moshi = + Moshi.Builder() + .add(LocalDateTimeAdapter()) + .add(LocalDateAdapter()) + .add(UnitJsonAdapter()) + .addLast(KotlinJsonAdapterFactory()) + .build() + + @Provides + @Singleton + fun provideMoshiConverterFactory(moshi: Moshi): MoshiConverterFactory = + MoshiConverterFactory.create(moshi) @Provides @Singleton @@ -107,13 +121,13 @@ object NetworkModule { @Singleton @PublicRetrofit fun providePublicRetrofit( - moshi: Moshi, + moshiConverterFactory: MoshiConverterFactory, @PublicOkHttpClient okHttpClient: OkHttpClient ): Retrofit { return Retrofit.Builder() .baseUrl(BASE_URL) .addCallAdapterFactory(ApiResultCallAdapterFactory()) - .addConverterFactory(MoshiConverterFactory.create(moshi)) + .addConverterFactory(moshiConverterFactory) .client(okHttpClient) .build() } @@ -122,13 +136,13 @@ object NetworkModule { @Singleton @AuthRetrofit fun provideAuthRetrofit( - moshi: Moshi, + moshiConverterFactory: MoshiConverterFactory, @AuthOkHttpClient okHttpClient: OkHttpClient ): Retrofit { return Retrofit.Builder() .baseUrl(BASE_URL) .addCallAdapterFactory(ApiResultCallAdapterFactory()) - .addConverterFactory(MoshiConverterFactory.create(moshi)) + .addConverterFactory(moshiConverterFactory) .client(okHttpClient) .build() } @@ -137,15 +151,15 @@ object NetworkModule { @Singleton @RefreshRetrofit fun provideRefreshRetrofit( - moshi: Moshi, + moshiConverterFactory: MoshiConverterFactory, @RefreshOkHttpClient okHttpClient: OkHttpClient ): Retrofit { return Retrofit.Builder() .baseUrl(BASE_URL) .addCallAdapterFactory(ApiResultCallAdapterFactory()) - .addConverterFactory(MoshiConverterFactory.create(moshi)) + .addConverterFactory(moshiConverterFactory) .client(okHttpClient) .build() } -} \ No newline at end of file +} diff --git a/android/data/src/main/java/com/sixkids/data/di/RepositoryModule.kt b/android/data/src/main/java/com/sixkids/data/di/RepositoryModule.kt index d1403bb0..63e148ae 100644 --- a/android/data/src/main/java/com/sixkids/data/di/RepositoryModule.kt +++ b/android/data/src/main/java/com/sixkids/data/di/RepositoryModule.kt @@ -1,18 +1,93 @@ package com.sixkids.data.di import com.sixkids.data.repository.TokenRepositoryImpl +import com.sixkids.data.repository.challenge.ChallengeRepositoryImpl +import com.sixkids.data.repository.chatting.ChattingRepositoryImpl +import com.sixkids.data.repository.chattingfilter.ChattingFilterRepositoryImpl +import com.sixkids.data.repository.comment.CommentRepositoryImpl +import com.sixkids.data.repository.group.GroupRepositoryImpl +import com.sixkids.data.repository.organization.OrganizationRepositoryImpl +import com.sixkids.data.repository.post.PostRepositoryImpl +import com.sixkids.data.repository.relay.RelayRepositoryImpl +import com.sixkids.data.repository.user.UserRepositoryImpl +import com.sixkids.domain.repository.ChallengeRepository +import com.sixkids.domain.repository.ChattingFilterRepository +import com.sixkids.domain.repository.ChattingRepository +import com.sixkids.domain.repository.CommentRepository +import com.sixkids.domain.repository.GroupRepository +import com.sixkids.domain.repository.OrganizationRepository +import com.sixkids.domain.repository.PostRepository +import com.sixkids.domain.repository.RelayRepository import com.sixkids.domain.repository.TokenRepository +import com.sixkids.domain.repository.UserRepository import dagger.Binds import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) abstract class RepositoryModule { + @Singleton @Binds abstract fun bindTokenRepository( tokenRepository: TokenRepositoryImpl ): TokenRepository -} \ No newline at end of file + + @Singleton + @Binds + abstract fun bindUserRepository( + userRepository: UserRepositoryImpl + ): UserRepository + + @Singleton + @Binds + abstract fun bindChallengeRepository( + challengeRepository: ChallengeRepositoryImpl + ): ChallengeRepository + + @Singleton + @Binds + abstract fun bindOrganizationRepository( + organizationRepository: OrganizationRepositoryImpl + ): OrganizationRepository + + @Singleton + @Binds + abstract fun bindPostRepository( + postRepository: PostRepositoryImpl + ): PostRepository + + @Singleton + @Binds + abstract fun bindCommentRepository( + commentRepository: CommentRepositoryImpl + ): CommentRepository + + @Singleton + @Binds + abstract fun bindChattingRepository( + chattingRepository: ChattingRepositoryImpl + ): ChattingRepository + + @Singleton + @Binds + abstract fun bindRelayRepository( + relayRepository: RelayRepositoryImpl + ): RelayRepository + + @Singleton + @Binds + abstract fun bindChattingFilterRepository( + chattingFilterRepository: ChattingFilterRepositoryImpl + ): ChattingFilterRepository + + @Singleton + @Binds + abstract fun bindGroupRepository( + groupRepository: GroupRepositoryImpl + ): GroupRepository + +} diff --git a/android/data/src/main/java/com/sixkids/data/di/ServiceModule.kt b/android/data/src/main/java/com/sixkids/data/di/ServiceModule.kt index b3a37da6..6136820b 100644 --- a/android/data/src/main/java/com/sixkids/data/di/ServiceModule.kt +++ b/android/data/src/main/java/com/sixkids/data/di/ServiceModule.kt @@ -1,5 +1,16 @@ package com.sixkids.data.di +import com.sixkids.data.api.ChallengeService +import com.sixkids.data.api.ChatFilterService +import com.sixkids.data.api.CommentService +import com.sixkids.data.api.ChattingService +import com.sixkids.data.api.GroupService +import com.sixkids.data.api.MemberOrgService +import com.sixkids.data.api.MemberService +import com.sixkids.data.api.OrganizationService +import com.sixkids.data.api.PostService +import com.sixkids.data.api.RelayService +import com.sixkids.data.api.SignInService import com.sixkids.data.api.TokenService import dagger.Module import dagger.Provides @@ -19,4 +30,93 @@ object ServiceModule { ): TokenService { return retrofit.create(TokenService::class.java) } -} \ No newline at end of file + + @Singleton + @Provides + fun provideSignInService( + @NetworkModule.PublicRetrofit retrofit: Retrofit + ): SignInService { + return retrofit.create(SignInService::class.java) + } + + @Singleton + @Provides + fun provideChallengeService( + @NetworkModule.AuthRetrofit retrofit: Retrofit + ): ChallengeService { + return retrofit.create(ChallengeService::class.java) + } + + @Singleton + @Provides + fun provideMemberService( + @NetworkModule.AuthRetrofit retrofit: Retrofit + ): MemberService { + return retrofit.create(MemberService::class.java) + } + + @Singleton + @Provides + fun provideOrganizationService( + @NetworkModule.AuthRetrofit retrofit: Retrofit + ): OrganizationService { + return retrofit.create(OrganizationService::class.java) + } + + @Singleton + @Provides + fun providePostService( + @NetworkModule.AuthRetrofit retrofit: Retrofit + ): PostService { + return retrofit.create(PostService::class.java) + } + + @Singleton + @Provides + fun provideCommentService( + @NetworkModule.AuthRetrofit retrofit: Retrofit + ): CommentService { + return retrofit.create(CommentService::class.java) + } + + @Singleton + @Provides + fun provideChattingService( + @NetworkModule.AuthRetrofit retrofit: Retrofit + ): ChattingService { + return retrofit.create(ChattingService::class.java) + } + + @Singleton + @Provides + fun provideRelayService( + @NetworkModule.AuthRetrofit retrofit: Retrofit + ): RelayService { + return retrofit.create(RelayService::class.java) + } + + @Singleton + @Provides + fun provideMemberOrgService( + @NetworkModule.AuthRetrofit retrofit: Retrofit + ): MemberOrgService { + return retrofit.create(MemberOrgService::class.java) + } + + @Singleton + @Provides + fun provideChatFilterService( + @NetworkModule.AuthRetrofit retrofit: Retrofit + ): ChatFilterService { + return retrofit.create(ChatFilterService::class.java) + } + + @Singleton + @Provides + fun provideGroupService( + @NetworkModule.AuthRetrofit retrofit: Retrofit + ): GroupService { + return retrofit.create(GroupService::class.java) + } + +} diff --git a/android/data/src/main/java/com/sixkids/data/model/request/ChallengeCreateRequest.kt b/android/data/src/main/java/com/sixkids/data/model/request/ChallengeCreateRequest.kt new file mode 100644 index 00000000..32b12f97 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/model/request/ChallengeCreateRequest.kt @@ -0,0 +1,20 @@ +package com.sixkids.data.model.request + +import java.time.LocalDateTime + +data class ChallengeCreateRequest( + val organizationId: Int, + val title: String, + val content: String, + val startTime: LocalDateTime, + val endTime: LocalDateTime, + val minCount: Int, + val reward: Int, + val groups: List +) + +data class GroupRequest( + val headCount: Int, + val leaderId: Long, + val students: List +) diff --git a/android/data/src/main/java/com/sixkids/data/model/request/ChatFilterRequest.kt b/android/data/src/main/java/com/sixkids/data/model/request/ChatFilterRequest.kt new file mode 100644 index 00000000..0f905fd3 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/model/request/ChatFilterRequest.kt @@ -0,0 +1,5 @@ +package com.sixkids.data.model.request + +data class ChatFilterRequest( + val badWord: String, +) diff --git a/android/data/src/main/java/com/sixkids/data/model/request/CommentRequest.kt b/android/data/src/main/java/com/sixkids/data/model/request/CommentRequest.kt new file mode 100644 index 00000000..7fde717d --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/model/request/CommentRequest.kt @@ -0,0 +1,13 @@ +package com.sixkids.data.model.request + +data class NewCommentRequest( + val boardId: Long, + val content: String, + val parentId: Long, +) + + +data class UpdateCommentRequest( + val id: Long, + val content: String, +) \ No newline at end of file diff --git a/android/data/src/main/java/com/sixkids/data/model/request/FcmRequest.kt b/android/data/src/main/java/com/sixkids/data/model/request/FcmRequest.kt new file mode 100644 index 00000000..b872b399 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/model/request/FcmRequest.kt @@ -0,0 +1,5 @@ +package com.sixkids.data.model.request + +data class FcmRequest( + val fcmToken: String, +) diff --git a/android/data/src/main/java/com/sixkids/data/model/request/GreetingRequest.kt b/android/data/src/main/java/com/sixkids/data/model/request/GreetingRequest.kt new file mode 100644 index 00000000..1e8d2738 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/model/request/GreetingRequest.kt @@ -0,0 +1,6 @@ +package com.sixkids.data.model.request + +data class GreetingRequest( + val organizationId: Long, + val memberId: Long +) diff --git a/android/data/src/main/java/com/sixkids/data/model/request/JoinOrganizationRequest.kt b/android/data/src/main/java/com/sixkids/data/model/request/JoinOrganizationRequest.kt new file mode 100644 index 00000000..a1c95f37 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/model/request/JoinOrganizationRequest.kt @@ -0,0 +1,5 @@ +package com.sixkids.data.model.request + +data class JoinOrganizationRequest( + val code: String +) diff --git a/android/data/src/main/java/com/sixkids/data/model/request/NewOrganizationRequest.kt b/android/data/src/main/java/com/sixkids/data/model/request/NewOrganizationRequest.kt new file mode 100644 index 00000000..fcac6884 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/model/request/NewOrganizationRequest.kt @@ -0,0 +1,5 @@ +package com.sixkids.data.model.request + +data class NewOrganizationRequest( + val name: String +) diff --git a/android/data/src/main/java/com/sixkids/data/model/request/NewPostRequest.kt b/android/data/src/main/java/com/sixkids/data/model/request/NewPostRequest.kt new file mode 100644 index 00000000..7331390d --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/model/request/NewPostRequest.kt @@ -0,0 +1,8 @@ +package com.sixkids.data.model.request + +data class NewPostRequest( + val title: String, + val content: String, + val secretStatus: Boolean, + val postCategory: String +) \ No newline at end of file diff --git a/android/data/src/main/java/com/sixkids/data/model/request/ReceiveRelayRequest.kt b/android/data/src/main/java/com/sixkids/data/model/request/ReceiveRelayRequest.kt new file mode 100644 index 00000000..340b6778 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/model/request/ReceiveRelayRequest.kt @@ -0,0 +1,6 @@ +package com.sixkids.data.model.request + +data class ReceiveRelayRequest( + val senderId: Long, + val question: String +) diff --git a/android/data/src/main/java/com/sixkids/data/model/request/RefreshTokenRequest.kt b/android/data/src/main/java/com/sixkids/data/model/request/RefreshTokenRequest.kt deleted file mode 100644 index a6bd0a4c..00000000 --- a/android/data/src/main/java/com/sixkids/data/model/request/RefreshTokenRequest.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.sixkids.data.model.request - -data class RefreshTokenRequest( - val accessToken: String, - val refreshToken: String - -) diff --git a/android/data/src/main/java/com/sixkids/data/model/request/RelayCreateRequest.kt b/android/data/src/main/java/com/sixkids/data/model/request/RelayCreateRequest.kt new file mode 100644 index 00000000..ed211845 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/model/request/RelayCreateRequest.kt @@ -0,0 +1,6 @@ +package com.sixkids.data.model.request + +data class RelayCreateRequest( + val organizationId: Int, + val question: String +) diff --git a/android/data/src/main/java/com/sixkids/data/model/request/SignInRequest.kt b/android/data/src/main/java/com/sixkids/data/model/request/SignInRequest.kt new file mode 100644 index 00000000..3482b3b0 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/model/request/SignInRequest.kt @@ -0,0 +1,5 @@ +package com.sixkids.data.model.request + +data class SignInRequest( + val idToken: String +) diff --git a/android/data/src/main/java/com/sixkids/data/model/request/SignUpRequest.kt b/android/data/src/main/java/com/sixkids/data/model/request/SignUpRequest.kt new file mode 100644 index 00000000..de550522 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/model/request/SignUpRequest.kt @@ -0,0 +1,10 @@ +package com.sixkids.data.model.request + +import okhttp3.MultipartBody + +data class SignUpRequest( + val idToken: String, + val file: MultipartBody.Part?, + val defaultImage: Int, + val role: String +) diff --git a/android/data/src/main/java/com/sixkids/data/model/response/ApiResponse.kt b/android/data/src/main/java/com/sixkids/data/model/response/ApiResponse.kt new file mode 100644 index 00000000..45d12ebe --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/model/response/ApiResponse.kt @@ -0,0 +1,7 @@ +package com.sixkids.data.model.response + +data class ApiResponse( + val message: String, + val status: String, + val data: T, +) \ No newline at end of file diff --git a/android/data/src/main/java/com/sixkids/data/model/response/ChallengeDetailResponse.kt b/android/data/src/main/java/com/sixkids/data/model/response/ChallengeDetailResponse.kt new file mode 100644 index 00000000..040728b8 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/model/response/ChallengeDetailResponse.kt @@ -0,0 +1,21 @@ +package com.sixkids.data.model.response + +import com.sixkids.model.ChallengeDetail +import com.squareup.moshi.Json + +data class ChallengeDetailResponse( + @Json(name = "challengeSimpleDTO") + val challenge: ChallengeResponse, + val reports: List +) + +internal fun ChallengeDetailResponse.toModel() = ChallengeDetail( + id = challenge.id, + title = challenge.title, + content = challenge.content, + startTime = challenge.startTime, + endTime = challenge.endTime, + headCount = reports.fold(0) { headCount, report -> headCount + report.group.headCount }, + teamCount = reports.size, + reportList = reports.map { it.toModel() } +) diff --git a/android/data/src/main/java/com/sixkids/data/model/response/ChallengeHistoryResponse.kt b/android/data/src/main/java/com/sixkids/data/model/response/ChallengeHistoryResponse.kt new file mode 100644 index 00000000..602fa0c9 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/model/response/ChallengeHistoryResponse.kt @@ -0,0 +1,31 @@ +package com.sixkids.data.model.response + +import com.sixkids.model.Challenge +import java.time.LocalDateTime + +data class ChallengeHistoryResponse( + val page: Int, + val size: Int, + val last: Boolean, + val totalCount : Int, + val challenges: List +) + +data class ChallengeResponse( + val id: Long = 0, + val title: String = "", + val content: String = "", + val headCount: Int = 0, + val startTime: LocalDateTime = LocalDateTime.now(), + val endTime: LocalDateTime = LocalDateTime.now(), +) + +internal fun ChallengeResponse.toModel(totalCount: Int = 0) = Challenge( + id = id, + title = title, + content = content, + headCount = headCount, + startTime = startTime, + endTime = endTime, + totalCount = totalCount +) diff --git a/android/data/src/main/java/com/sixkids/data/model/response/ChallengeSimpleResponse.kt b/android/data/src/main/java/com/sixkids/data/model/response/ChallengeSimpleResponse.kt new file mode 100644 index 00000000..fd967e56 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/model/response/ChallengeSimpleResponse.kt @@ -0,0 +1,24 @@ +package com.sixkids.data.model.response + +import com.sixkids.model.Challenge +import java.time.LocalDateTime + +data class ChallengeSimpleResponse( + val id: Long, + val title: String, + val content: String, + val startTime: LocalDateTime, + val endTime: LocalDateTime, + val reward: Int, +) + +fun ChallengeSimpleResponse.toModel(): Challenge { + return Challenge( + id = id, + title = title, + content = content, + startTime = startTime, + endTime = endTime, + reward = reward + ) +} diff --git a/android/data/src/main/java/com/sixkids/data/model/response/ChatHistoryResponse.kt b/android/data/src/main/java/com/sixkids/data/model/response/ChatHistoryResponse.kt new file mode 100644 index 00000000..c89a7d0b --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/model/response/ChatHistoryResponse.kt @@ -0,0 +1,29 @@ +package com.sixkids.data.model.response + +import com.sixkids.model.Chat + +data class ChatHistoryResponse( + val roomId: Long, + val hasNext: Boolean, + val messages: List +) + +data class MessageResponse( + val memberId: Long, + val memberName: String, + val memberImageUrl: String?, + val content: String, + val sendDateTime: Long, + val readCount: Int +) + +internal fun MessageResponse.toModel() : Chat { + return Chat( + memberId = memberId, + memberName = memberName, + memberImageUrl = memberImageUrl?:"https://ulvanbucket.s3.ap-northeast-2.amazonaws.com/5001fcad-52b8-42ba-a587-c72797a3a36f_profile.jpg", + content = content, + sendDateTime = sendDateTime, + readCount = readCount + ) +} \ No newline at end of file diff --git a/android/data/src/main/java/com/sixkids/data/model/response/ChattingFilterListResponse.kt b/android/data/src/main/java/com/sixkids/data/model/response/ChattingFilterListResponse.kt new file mode 100644 index 00000000..7028d84a --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/model/response/ChattingFilterListResponse.kt @@ -0,0 +1,20 @@ +package com.sixkids.data.model.response + +import com.sixkids.model.ChatFilterWord + +data class ChattingFilterListResponse( + val hasNext: Boolean, + val words: List, +) + +data class ChattingFilterResponse( + val id: Long, + val badWord: String, +) + +fun ChattingFilterResponse.toModel(): ChatFilterWord { + return ChatFilterWord( + id = id, + badWord = badWord, + ) +} \ No newline at end of file diff --git a/android/data/src/main/java/com/sixkids/data/model/response/ClassSummaryResponse.kt b/android/data/src/main/java/com/sixkids/data/model/response/ClassSummaryResponse.kt new file mode 100644 index 00000000..8dfefefc --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/model/response/ClassSummaryResponse.kt @@ -0,0 +1,30 @@ +package com.sixkids.data.model.response + +import com.sixkids.model.ClassSummary +import com.sixkids.model.MemberSimpleClassSummary + +data class ClassSummaryResponse( + val challengeCounts: List, + val relayCounts: List, + val postCounts: List, +) + +data class ClassSummaryMemberResponse( + val member: MemberSimpleInfoResponse, + val count: Int +) + +fun ClassSummaryMemberResponse.toModel(): MemberSimpleClassSummary { + return MemberSimpleClassSummary( + member.toModel(), + count + ) +} + +internal fun ClassSummaryResponse.toModel(): ClassSummary { + return ClassSummary( + challengeCounts.map { it.toModel() }, + relayCounts.map { it.toModel() }, + postCounts.map { it.toModel() } + ) +} \ No newline at end of file diff --git a/android/data/src/main/java/com/sixkids/data/model/response/GroupMatchingRoomResponse.kt b/android/data/src/main/java/com/sixkids/data/model/response/GroupMatchingRoomResponse.kt new file mode 100644 index 00000000..ff44d792 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/model/response/GroupMatchingRoomResponse.kt @@ -0,0 +1,14 @@ +package com.sixkids.data.model.response + +import com.sixkids.model.MatchingRoom + +data class GroupMatchingRoomResponse( + val dataKey: String, + val minCount: Int +) + +internal fun GroupMatchingRoomResponse.toModel(): MatchingRoom = + MatchingRoom( + dataKey = dataKey, + minCount = minCount + ) diff --git a/android/data/src/main/java/com/sixkids/data/model/response/GroupMatchingSuccessResponse.kt b/android/data/src/main/java/com/sixkids/data/model/response/GroupMatchingSuccessResponse.kt new file mode 100644 index 00000000..faba86d8 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/model/response/GroupMatchingSuccessResponse.kt @@ -0,0 +1,14 @@ +package com.sixkids.data.model.response + +import com.sixkids.model.ChallengeGroup + +data class GroupMatchingSuccessResponse( + val members: List, + val headCount: Int +) + +internal fun GroupMatchingSuccessResponse.toModel(): ChallengeGroup = + ChallengeGroup( + headCount = headCount, + memberList = members.map { it.toModel() } + ) diff --git a/android/data/src/main/java/com/sixkids/data/model/response/GroupResponse.kt b/android/data/src/main/java/com/sixkids/data/model/response/GroupResponse.kt new file mode 100644 index 00000000..98260370 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/model/response/GroupResponse.kt @@ -0,0 +1,16 @@ +package com.sixkids.data.model.response + +import com.sixkids.model.Group + +data class GroupResponse( + val id: Long, + val headCount: Int, + val leaderId: Long, + val students: List, +) + +internal fun GroupResponse.toModel() = Group( + id = id, + leaderId = leaderId, + studentList = students.map { it.toModel() } +) diff --git a/android/data/src/main/java/com/sixkids/data/model/response/MemberInfoResponse.kt b/android/data/src/main/java/com/sixkids/data/model/response/MemberInfoResponse.kt new file mode 100644 index 00000000..2b3c3375 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/model/response/MemberInfoResponse.kt @@ -0,0 +1,36 @@ +package com.sixkids.data.model.response + +import com.sixkids.model.MemberSimple +import com.sixkids.model.UserInfo + +data class MemberInfoResponse( + val id: Int, + val name: String, + val email: String, + val photo: String, + val role: String +) + +data class MemberSimpleInfoResponse( + val id: Long, + val name: String, + val photo: String? +) + +internal fun MemberInfoResponse.toModel(): UserInfo { + return UserInfo( + id = id, + name = name, + email = email, + photo = photo, + role = role + ) +} + +internal fun MemberSimpleInfoResponse.toModel(): MemberSimple { + return MemberSimple( + id = id, + name = name, + photo = photo?: "" + ) +} \ No newline at end of file diff --git a/android/data/src/main/java/com/sixkids/data/model/response/OrganizationInviteCodeResponse.kt b/android/data/src/main/java/com/sixkids/data/model/response/OrganizationInviteCodeResponse.kt new file mode 100644 index 00000000..c5d435cf --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/model/response/OrganizationInviteCodeResponse.kt @@ -0,0 +1,5 @@ +package com.sixkids.data.model.response + +data class OrganizationInviteCodeResponse( + val code: String +) diff --git a/android/data/src/main/java/com/sixkids/data/model/response/OrganizationMemberResponse.kt b/android/data/src/main/java/com/sixkids/data/model/response/OrganizationMemberResponse.kt new file mode 100644 index 00000000..2ff5854f --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/model/response/OrganizationMemberResponse.kt @@ -0,0 +1,15 @@ +package com.sixkids.data.model.response + +import com.sixkids.model.MemberSimple + +data class OrganizationMemberResponse( + val id: Int, + val name: String, + val photo: String +) + +internal fun OrganizationMemberResponse.toModel() = MemberSimple( + id = id.toLong(), + name = name, + photo = photo +) \ No newline at end of file diff --git a/android/data/src/main/java/com/sixkids/data/model/response/OrganizationNameResponse.kt b/android/data/src/main/java/com/sixkids/data/model/response/OrganizationNameResponse.kt new file mode 100644 index 00000000..c1c90717 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/model/response/OrganizationNameResponse.kt @@ -0,0 +1,5 @@ +package com.sixkids.data.model.response + +data class OrganizationNameResponse( + val name: String +) diff --git a/android/data/src/main/java/com/sixkids/data/model/response/OrganizationResponse.kt b/android/data/src/main/java/com/sixkids/data/model/response/OrganizationResponse.kt new file mode 100644 index 00000000..d9469e91 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/model/response/OrganizationResponse.kt @@ -0,0 +1,17 @@ +package com.sixkids.data.model.response + +import com.sixkids.model.Organization +import java.time.LocalDate + +data class OrganizationResponse( + val id: Int, + val name: String, + val memberCount: Int, + val createTime: LocalDate = LocalDate.now(), +) + +internal fun OrganizationResponse.toModel() = Organization( + id = id, + name = name, + memberCount = memberCount +) \ No newline at end of file diff --git a/android/data/src/main/java/com/sixkids/data/model/response/PostDetailResponse.kt b/android/data/src/main/java/com/sixkids/data/model/response/PostDetailResponse.kt new file mode 100644 index 00000000..2cbfeeda --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/model/response/PostDetailResponse.kt @@ -0,0 +1,73 @@ +package com.sixkids.data.model.response + +import com.sixkids.model.Comment +import com.sixkids.model.PostDetail +import com.sixkids.model.Recomment +import com.squareup.moshi.Json +import java.time.LocalDateTime + +data class PostDetailResponse( + @Json(name = "member") + val writer: MemberSimpleInfoResponse, + val createTime: LocalDateTime, + val updateTime: LocalDateTime?, + val title: String, + val content: String, + @Json(name = "path") + val photoUrl: String?, + val comments: List, +) + +data class CommentResponse( + val id: Long, + @Json(name = "member") + val writer: MemberSimpleInfoResponse, + val content: String, + val createTime: LocalDateTime, + val updateTime: LocalDateTime?, + @Json(name = "children") + val recommentList: List, +) + +data class RecommentResponse( + val id: Long, + @Json(name = "member") + val writer: MemberSimpleInfoResponse, + val content: String, + val createTime: LocalDateTime, + val updateTime: LocalDateTime?, + val parentId: Long, +) + +fun PostDetailResponse.toModel(): PostDetail { + return PostDetail( + createTime = createTime, + title = title, + content = content, + imageUri = photoUrl ?: "", + comments = comments.map { it.toModel() }, + writeMember = writer.toModel() + ) +} + +fun CommentResponse.toModel(): Comment{ + return Comment( + id = id, + content = content, + createTime = createTime, + updateTime = updateTime, + member = writer.toModel(), + recomments = recommentList.map { it.toModel() } + ) +} + +fun RecommentResponse.toModel(): Recomment { + return Recomment( + id = id, + content = content, + createTime = createTime, + updateTime = updateTime, + parentId = parentId, + member = writer.toModel() + ) +} diff --git a/android/data/src/main/java/com/sixkids/data/model/response/PostListResponse.kt b/android/data/src/main/java/com/sixkids/data/model/response/PostListResponse.kt new file mode 100644 index 00000000..bce48f60 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/model/response/PostListResponse.kt @@ -0,0 +1,31 @@ +package com.sixkids.data.model.response + +import com.sixkids.model.Post +import com.squareup.moshi.Json +import java.time.LocalDateTime + +data class PostListResponse( + val page: Int, + val size: Int, + val hasNextPage: Boolean, + val posts: List +) + +data class PostResponse( + val id: Long, + val title: String, + val author: String, + @Json(name = "CommentCount") + val commentCount: Int, + val createTime: LocalDateTime = LocalDateTime.now(), +) + +fun PostResponse.toModel(): Post { + return Post( + id = id, + title = title, + writer = author, + commentCount = commentCount, + time = createTime + ) +} \ No newline at end of file diff --git a/android/data/src/main/java/com/sixkids/data/model/response/RankResponse.kt b/android/data/src/main/java/com/sixkids/data/model/response/RankResponse.kt new file mode 100644 index 00000000..151ea29a --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/model/response/RankResponse.kt @@ -0,0 +1,18 @@ +package com.sixkids.data.model.response + +import com.sixkids.model.MemberRankItem + +data class RankResponse( + val name: String, + val point: Int, +) + +fun List.toModel(): List { + return this.mapIndexed { index, rankResponse -> + MemberRankItem( + rank = index + 1, + name = rankResponse.name, + exp = rankResponse.point, + ) + } +} \ No newline at end of file diff --git a/android/data/src/main/java/com/sixkids/data/model/response/ReceiveRelayResponse.kt b/android/data/src/main/java/com/sixkids/data/model/response/ReceiveRelayResponse.kt new file mode 100644 index 00000000..838c7573 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/model/response/ReceiveRelayResponse.kt @@ -0,0 +1,15 @@ +package com.sixkids.data.model.response + +data class ReceiveRelayResponse( + val senderName: String, + val question: String, + val lastStatus: Boolean, + val demerit: Int, +) + +internal fun ReceiveRelayResponse.toModel() = com.sixkids.model.RelayReceive( + senderName = senderName, + question = question, + lastStatus = lastStatus, + demerit = demerit, +) \ No newline at end of file diff --git a/android/data/src/main/java/com/sixkids/data/model/response/RelayDetailResponse.kt b/android/data/src/main/java/com/sixkids/data/model/response/RelayDetailResponse.kt new file mode 100644 index 00000000..939259f9 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/model/response/RelayDetailResponse.kt @@ -0,0 +1,17 @@ +package com.sixkids.data.model.response + +import com.sixkids.model.RelayDetail + +data class RelayDetailResponse( + val relaySimple: RelayResponse, + val runners: List +) + +internal fun RelayDetailResponse.toModel() = RelayDetail( + id = relaySimple.id, + startTime = relaySimple.startTime, + endTime = relaySimple.endTime, + lastTurn = relaySimple.lastTurn, + lastMemberName = relaySimple.lastMemberName, + runnerList = runners.map { it.toModel() } +) \ No newline at end of file diff --git a/android/data/src/main/java/com/sixkids/data/model/response/RelayHistoryResponse.kt b/android/data/src/main/java/com/sixkids/data/model/response/RelayHistoryResponse.kt new file mode 100644 index 00000000..4b060b33 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/model/response/RelayHistoryResponse.kt @@ -0,0 +1,29 @@ +package com.sixkids.data.model.response + +import com.sixkids.model.Relay +import java.time.LocalDateTime + +data class RelayHistoryResponse( + val page: Int, + val size: Int, + val last: Boolean, + val totalCount: Int, + val relays: List +) + +data class RelayResponse( + val id : Long, + val startTime : LocalDateTime = LocalDateTime.now(), + val endTime : LocalDateTime = LocalDateTime.now(), + val lastTurn : Int, + val lastMemberName : String +) + +internal fun RelayResponse.toModel(totalCount: Int) = Relay( + id = id, + startTime = startTime, + endTime = endTime, + lastTurn = lastTurn, + lastMemberName = lastMemberName, + totalCount = totalCount +) diff --git a/android/data/src/main/java/com/sixkids/data/model/response/ReportResponse.kt b/android/data/src/main/java/com/sixkids/data/model/response/ReportResponse.kt new file mode 100644 index 00000000..0f15130e --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/model/response/ReportResponse.kt @@ -0,0 +1,25 @@ +package com.sixkids.data.model.response + +import com.sixkids.model.AcceptStatus +import com.sixkids.model.Report +import java.time.LocalDateTime + +data class ReportResponse( + val id: Long, + val group: GroupResponse, + val startTime: LocalDateTime, + val endTime: LocalDateTime, + val file: String, + val content: String, + val acceptStatus: String, +) + +internal fun ReportResponse.toModel() = Report( + id = id, + group = group.toModel(), + startTime = startTime, + endTime = endTime, + file = file, + content = content, + acceptStatus = AcceptStatus.valueOf(acceptStatus), +) diff --git a/android/data/src/main/java/com/sixkids/data/model/response/RunnerResponse.kt b/android/data/src/main/java/com/sixkids/data/model/response/RunnerResponse.kt new file mode 100644 index 00000000..019161eb --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/model/response/RunnerResponse.kt @@ -0,0 +1,37 @@ +package com.sixkids.data.model.response + +import com.sixkids.model.MemberSimple +import com.sixkids.model.RelayQuestion +import java.time.LocalDateTime + +data class RunnerResponse( + val id: Long, + val turn: Int, + val member: MemberSimpleResponse, + val time: LocalDateTime = LocalDateTime.now(), + val question: String, + val endStatus: Boolean +) + +data class MemberSimpleResponse( + val id: Long, + val name: String, + val photo: String, +) + +internal fun RunnerResponse.toModel() = RelayQuestion( + id = id, + memberId = member.id, + memberName = member.name, + memberPhoto = member.photo, + time = time, + question = question, + turn = turn, + endStatus = endStatus, +) + +internal fun MemberSimpleResponse.toModel() = MemberSimple( + id = id, + name = name, + photo = photo, +) diff --git a/android/data/src/main/java/com/sixkids/data/model/response/RunningChallengeByStudentResponse.kt b/android/data/src/main/java/com/sixkids/data/model/response/RunningChallengeByStudentResponse.kt new file mode 100644 index 00000000..4d759292 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/model/response/RunningChallengeByStudentResponse.kt @@ -0,0 +1,27 @@ +package com.sixkids.data.model.response + +import com.sixkids.model.GroupType +import com.sixkids.model.RunningChallengeByStudent +import com.squareup.moshi.Json +import java.time.LocalDateTime + +data class RunningChallengeByStudentResponse( + @Json(name = "challengeSimpleDTO") + val challenge: ChallengeResponse, + val leaderStatus: Boolean?, + @Json(name = "memberNames") + val memberList: List?, + val type: String, + val createTime: LocalDateTime?, + val endStatus: Boolean? +) + +internal fun RunningChallengeByStudentResponse.toModel() = + RunningChallengeByStudent( + challenge = challenge.toModel(), + leaderStatus = leaderStatus, + memberNames = memberList?.map { it.toModel() } ?: emptyList(), + type = GroupType.valueOf(type), + createTime = createTime, + endStatus = endStatus + ) diff --git a/android/data/src/main/java/com/sixkids/data/model/response/RunningChallengeResponse.kt b/android/data/src/main/java/com/sixkids/data/model/response/RunningChallengeResponse.kt new file mode 100644 index 00000000..51502fa9 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/model/response/RunningChallengeResponse.kt @@ -0,0 +1,25 @@ +package com.sixkids.data.model.response + +import com.sixkids.model.RunningChallenge +import com.squareup.moshi.Json + +data class RunningChallengeResponse( + @Json(name = "challengeSimpleDTO") + val challenge: ChallengeResponse, + val waitingCount: Int, + val doneMemberCount: Int +) + +internal fun RunningChallengeResponse.toModel(): RunningChallenge { + val challenge = challenge.toModel() + return RunningChallenge( + id = challenge.id, + title = challenge.title, + content = challenge.content, + totalMemberCount = challenge.headCount, + doneMemberCount = doneMemberCount, + startTime = challenge.startTime, + endTime = challenge.endTime, + waitingCount = waitingCount + ) +} diff --git a/android/data/src/main/java/com/sixkids/data/model/response/RunningRelayResponse.kt b/android/data/src/main/java/com/sixkids/data/model/response/RunningRelayResponse.kt new file mode 100644 index 00000000..85699e90 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/model/response/RunningRelayResponse.kt @@ -0,0 +1,22 @@ +package com.sixkids.data.model.response + +import com.sixkids.model.RunningRelay +import java.time.LocalDateTime + +data class RunningRelayResponse( + val id: Long, + val startTime: LocalDateTime = LocalDateTime.now(), + val currentMemberName : String, + val currentTurn : Int, + val totalTurn : Int, + val myTurnStatus : Boolean +) + +internal fun RunningRelayResponse.toModel() = RunningRelay( + id = id, + startTime = startTime, + curMemberNickname = currentMemberName, + doneMemberCount = currentTurn, + totalMemberCount = totalTurn, + myTurnStatus = myTurnStatus +) \ No newline at end of file diff --git a/android/data/src/main/java/com/sixkids/data/model/response/SendRelayResponse.kt b/android/data/src/main/java/com/sixkids/data/model/response/SendRelayResponse.kt new file mode 100644 index 00000000..2976a8c0 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/model/response/SendRelayResponse.kt @@ -0,0 +1,13 @@ +package com.sixkids.data.model.response + +import com.sixkids.model.RelaySend + +data class SendRelayResponse( + val prevMemberName: String, + val prevQuestion: String +) + +internal fun SendRelayResponse.toModel() = RelaySend( + prevMemberName = prevMemberName, + prevQuestion = prevQuestion +) \ No newline at end of file diff --git a/android/data/src/main/java/com/sixkids/data/model/response/SignInResponse.kt b/android/data/src/main/java/com/sixkids/data/model/response/SignInResponse.kt new file mode 100644 index 00000000..0ef61a92 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/model/response/SignInResponse.kt @@ -0,0 +1,14 @@ +package com.sixkids.data.model.response + +import com.sixkids.model.JwtToken + +data class SignInResponse( + val accessToken : String, + val refreshToken : String, + val role : String +) + +internal fun SignInResponse.toModel() = JwtToken( + accessToken = accessToken, + refreshToken = refreshToken +) diff --git a/android/data/src/main/java/com/sixkids/data/model/response/SignUpResponse.kt b/android/data/src/main/java/com/sixkids/data/model/response/SignUpResponse.kt new file mode 100644 index 00000000..39c52824 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/model/response/SignUpResponse.kt @@ -0,0 +1,14 @@ +package com.sixkids.data.model.response + +import com.sixkids.model.JwtToken + +data class SignUpResponse ( + val accessToken: String, + val refreshToken: String, + val role: String +) + +internal fun SignUpResponse.toModel() = JwtToken( + accessToken = accessToken, + refreshToken = refreshToken +) diff --git a/android/data/src/main/java/com/sixkids/data/model/response/StudentDetailResponse.kt b/android/data/src/main/java/com/sixkids/data/model/response/StudentDetailResponse.kt new file mode 100644 index 00000000..0028082b --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/model/response/StudentDetailResponse.kt @@ -0,0 +1,27 @@ +package com.sixkids.data.model.response + +import com.sixkids.model.MemberDetail + +data class StudentDetailResponse( + val name: String = "", + val photo: String = "", + val isolationPoint: Double = 0.0, + val isolationRank: Int = -1, + val exp: Int = -1, + val challengeCount: Int = -1, + val relayCount: Int = -1, + val postCount: Int = -1, +) + +internal fun StudentDetailResponse.toModel(): MemberDetail { + return MemberDetail( + name = this.name, + photo = this.photo, + isolationPoint = this.isolationPoint, + isolationRank = this.isolationRank, + exp = this.exp, + challengeCount = this.challengeCount, + relayCount = this.relayCount, + postCount = this.postCount, + ) +} diff --git a/android/data/src/main/java/com/sixkids/data/model/response/StudentHomeResponse.kt b/android/data/src/main/java/com/sixkids/data/model/response/StudentHomeResponse.kt new file mode 100644 index 00000000..b27d861f --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/model/response/StudentHomeResponse.kt @@ -0,0 +1,23 @@ +package com.sixkids.data.model.response + +import com.sixkids.model.StudentHomeInfo +import com.squareup.moshi.Json + +data class StudentHomeResponse( + val name: String, + val photo: String, + @Json(name = "organizationName") + val className: String, + val exp: Int, + val notifyCount: Int, + val relations: List, +) + +internal fun StudentHomeResponse.toModel() = StudentHomeInfo( + name = name, + photo = photo, + className = className, + exp = exp, + notifyCount = notifyCount, + relations = relations.map { it.toModel() }, +) \ No newline at end of file diff --git a/android/data/src/main/java/com/sixkids/data/model/response/StudentRelationDetailResponse.kt b/android/data/src/main/java/com/sixkids/data/model/response/StudentRelationDetailResponse.kt new file mode 100644 index 00000000..298e2c0f --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/model/response/StudentRelationDetailResponse.kt @@ -0,0 +1,23 @@ +package com.sixkids.data.model.response + +import com.sixkids.model.StudentRelation + +data class StudentRelationDetailResponse( + val memberId: Long = -1, + val memberName: String = "", + val relationPoint: Int = 0, + val tagGreetingCount: Int = 0, + val groupCount: Int = 0, + val receiveCount: Int = 0, + val sendCount: Int = 0, +) + +internal fun StudentRelationDetailResponse.toModel() = StudentRelation( + id = memberId, + name = memberName, + relationPoint = relationPoint, + tagGreetingCount = tagGreetingCount, + groupCount = groupCount, + receiveCount = receiveCount, + sendCount = sendCount, +) \ No newline at end of file diff --git a/android/data/src/main/java/com/sixkids/data/model/response/StudentResponse.kt b/android/data/src/main/java/com/sixkids/data/model/response/StudentResponse.kt new file mode 100644 index 00000000..4dd45dab --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/model/response/StudentResponse.kt @@ -0,0 +1,28 @@ +package com.sixkids.data.model.response + +import com.sixkids.model.MemberSimple +import com.sixkids.model.MemberSimpleWithScore +import com.squareup.moshi.Json + +data class StudentResponse( + val id: Long, + val name: String, + val photo: String, +) + +data class StudentWithRelationScoreResponse( + @Json(name = "member") + val student: StudentResponse, + val relationPoint: Int, +) + +internal fun StudentResponse.toModel() = MemberSimple( + id = id, + name = name, + photo = photo, +) + +internal fun StudentWithRelationScoreResponse.toModel() = MemberSimpleWithScore( + memberSimple = student.toModel(), + relationPoint = relationPoint, +) diff --git a/android/data/src/main/java/com/sixkids/data/model/response/TokenResponse.kt b/android/data/src/main/java/com/sixkids/data/model/response/TokenResponse.kt index 2900fb29..4a5596f1 100644 --- a/android/data/src/main/java/com/sixkids/data/model/response/TokenResponse.kt +++ b/android/data/src/main/java/com/sixkids/data/model/response/TokenResponse.kt @@ -4,3 +4,8 @@ data class TokenResponse( val accessToken: String, val refreshToken: String ) + +internal fun TokenResponse.toModel() = com.sixkids.model.JwtToken( + accessToken = accessToken, + refreshToken = refreshToken +) \ No newline at end of file diff --git a/android/data/src/main/java/com/sixkids/data/model/response/UpdateProfilePhotoResponse.kt b/android/data/src/main/java/com/sixkids/data/model/response/UpdateProfilePhotoResponse.kt new file mode 100644 index 00000000..146e3249 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/model/response/UpdateProfilePhotoResponse.kt @@ -0,0 +1,5 @@ +package com.sixkids.data.model.response + +data class UpdateProfilePhotoResponse ( + val photoImageUrl: String +) diff --git a/android/data/src/main/java/com/sixkids/data/network/ApiResponse.kt b/android/data/src/main/java/com/sixkids/data/network/ApiResponse.kt new file mode 100644 index 00000000..7648655a --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/network/ApiResponse.kt @@ -0,0 +1,10 @@ +package com.sixkids.data.network + +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class ApiResponse( + val message: String, + val status: String, + val data: T, +) diff --git a/android/data/src/main/java/com/sixkids/data/network/ApiResult.kt b/android/data/src/main/java/com/sixkids/data/network/ApiResult.kt index 157c92a8..d22b8f57 100644 --- a/android/data/src/main/java/com/sixkids/data/network/ApiResult.kt +++ b/android/data/src/main/java/com/sixkids/data/network/ApiResult.kt @@ -56,4 +56,4 @@ private fun handleHttpError(httpError: ApiResult.Failure.HttpError): Exception = 404 -> NotFoundException() 500, 501, 502, 503, 504, 505 -> NetworkException() else -> UnknownException() -} \ No newline at end of file +} diff --git a/android/data/src/main/java/com/sixkids/data/network/ApiResultCallAdapter.kt b/android/data/src/main/java/com/sixkids/data/network/ApiResultCallAdapter.kt index 64597c47..4d3db04d 100644 --- a/android/data/src/main/java/com/sixkids/data/network/ApiResultCallAdapter.kt +++ b/android/data/src/main/java/com/sixkids/data/network/ApiResultCallAdapter.kt @@ -13,22 +13,21 @@ import java.lang.reflect.Type private const val TAG = "ApiResultCallAdapter_hong" -class ApiResultCallAdapter( - private val successType: Type +internal class ApiResultCallAdapter( + private val successType: Type, ) : CallAdapter>> { + override fun adapt(call: Call): Call> = ApiResultCall(call, successType) override fun responseType(): Type = successType - - override fun adapt(call: Call): Call> = - ApiResultCall(call, successType) } + private class ApiResultCall( private val delegate: Call, private val successType: Type, ) : Call> { - override fun enqueue(callback: Callback>) { - delegate.enqueue(object : Callback { + override fun enqueue(callback: Callback>) = delegate.enqueue( + object : Callback { override fun onResponse(call: Call, response: Response) { callback.onResponse(this@ApiResultCall, Response.success(response.toApiResult())) } @@ -49,13 +48,9 @@ private class ApiResultCall( @Suppress("UNCHECKED_CAST") (ApiResult.successOf(Unit as R)) } else { - Log.d( - TAG, - "toApiResult: successType이 Unit이 아닌 값입니다. 확인하세요. successType: $successType" - ) ApiResult.Failure.UnknownApiError( IllegalStateException( - "successType이 Unit이 아닌 값입니다. 확인하세요. successType: $successType", + "Body가 존재하지 않지만, Unit 이외의 타입으로 정의했습니다. ApiResult로 정의하세요.", ), ) } @@ -69,24 +64,21 @@ private class ApiResultCall( } callback.onResponse(this@ApiResultCall, Response.success(error)) } - }) - } + }, + ) override fun clone(): Call> = ApiResultCall(delegate.clone(), successType) - override fun execute(): Response> { - val response = delegate.execute() - return if (response.isSuccessful && response.body() != null) { - Response.success(ApiResult.Success(response.body()!!)) - } else { - Response.error(response.code(), response.errorBody()!!) - } - } + override fun execute(): Response> = + throw UnsupportedOperationException("This adapter doesn't support sync execution") override fun isExecuted(): Boolean = delegate.isExecuted + override fun cancel() = delegate.cancel() + override fun isCanceled(): Boolean = delegate.isCanceled + override fun request(): Request = delegate.request() - override fun timeout(): Timeout = delegate.timeout() -} \ No newline at end of file + override fun timeout(): Timeout = delegate.timeout() +} diff --git a/android/data/src/main/java/com/sixkids/data/network/ApiResultCallAdapterFactory.kt b/android/data/src/main/java/com/sixkids/data/network/ApiResultCallAdapterFactory.kt index efd085bf..54a539a9 100644 --- a/android/data/src/main/java/com/sixkids/data/network/ApiResultCallAdapterFactory.kt +++ b/android/data/src/main/java/com/sixkids/data/network/ApiResultCallAdapterFactory.kt @@ -1,5 +1,6 @@ package com.sixkids.data.network +import retrofit2.Call import retrofit2.CallAdapter import retrofit2.Retrofit import java.lang.reflect.ParameterizedType @@ -9,14 +10,14 @@ class ApiResultCallAdapterFactory : CallAdapter.Factory() { override fun get( returnType: Type, annotations: Array, - retrofit: Retrofit + retrofit: Retrofit, ): CallAdapter<*, *>? { val wrapperType = getParameterUpperBound(0, returnType as ParameterizedType) - if (getRawType(returnType) != ApiResult::class.java) { + if (getRawType(returnType) != Call::class.java) { return null } val bodyType = getParameterUpperBound(0, wrapperType as ParameterizedType) return ApiResultCallAdapter(bodyType) } -} \ No newline at end of file +} diff --git a/android/data/src/main/java/com/sixkids/data/network/RefreshTokenInterceptor.kt b/android/data/src/main/java/com/sixkids/data/network/RefreshTokenInterceptor.kt index 460a7d5b..9b94340d 100644 --- a/android/data/src/main/java/com/sixkids/data/network/RefreshTokenInterceptor.kt +++ b/android/data/src/main/java/com/sixkids/data/network/RefreshTokenInterceptor.kt @@ -11,15 +11,20 @@ class RefreshTokenInterceptor @Inject constructor( ) : Interceptor { companion object { const val AUTHORIZATION_HEADER = "Authorization" + const val REFRESH_HEADER = "refreshToken" const val TOKEN_TYPE = "Bearer" } override fun intercept(chain: Interceptor.Chain): Response { - val token = runBlocking { + val atk = runBlocking { + tokenRepository.getAccessToken() + } + val rtk = runBlocking { tokenRepository.getRefreshToken() } val request = chain.request().newBuilder().apply { - addHeader(AUTHORIZATION_HEADER, "$TOKEN_TYPE $token") + addHeader(AUTHORIZATION_HEADER, "$TOKEN_TYPE $atk") + addHeader(AUTHORIZATION_HEADER, "$REFRESH_HEADER $rtk") } return chain.proceed(request.build()) diff --git a/android/data/src/main/java/com/sixkids/data/network/TokenAuthenticator.kt b/android/data/src/main/java/com/sixkids/data/network/TokenAuthenticator.kt index 8bbbec2e..37980770 100644 --- a/android/data/src/main/java/com/sixkids/data/network/TokenAuthenticator.kt +++ b/android/data/src/main/java/com/sixkids/data/network/TokenAuthenticator.kt @@ -1,7 +1,6 @@ package com.sixkids.data.network import com.sixkids.data.api.TokenService -import com.sixkids.data.model.request.RefreshTokenRequest import com.sixkids.domain.repository.TokenRepository import kotlinx.coroutines.runBlocking import okhttp3.Authenticator @@ -20,29 +19,22 @@ class TokenAuthenticator @Inject constructor( const val TOKEN_TYPE = "Bearer" } override fun authenticate(route: Route?, response: Response): Request? { - val refreshToken = runBlocking { - tokenRepository.getRefreshToken() - } - val accessToken = runBlocking { - tokenRepository.getAccessToken() - } - return runBlocking { - val tokenResponse = tokenService.refreshToken( - RefreshTokenRequest(accessToken = accessToken, refreshToken = refreshToken), - ) + val tokenResponse = tokenService.refreshToken() // 2-1. 정상적으로 받지 못하면 request token 까지 만료된 것. - if(tokenResponse.isFailure || tokenResponse.getOrNull == null) { + if(tokenResponse.isFailure || tokenResponse.getOrNull() == null) { tokenRepository.clearTokens() null } else { + tokenRepository.saveIdToken(tokenResponse.getOrNull()!!.data.accessToken) + tokenRepository.saveRefreshToken(tokenResponse.getOrNull()!!.data.refreshToken) // 3. 헤더에 토큰을 교체한 request 생성 response.request.newBuilder() - .header(AUTHORIZATION_HEADER, "$TOKEN_TYPE ${tokenResponse.getOrNull.accessToken}") + .header(AUTHORIZATION_HEADER, "$TOKEN_TYPE ${tokenResponse.getOrNull()!!.data.accessToken}") .build() } } } -} \ No newline at end of file +} diff --git a/android/data/src/main/java/com/sixkids/data/network/TokenInterceptor.kt b/android/data/src/main/java/com/sixkids/data/network/TokenInterceptor.kt index e2faf278..5dc18195 100644 --- a/android/data/src/main/java/com/sixkids/data/network/TokenInterceptor.kt +++ b/android/data/src/main/java/com/sixkids/data/network/TokenInterceptor.kt @@ -25,4 +25,4 @@ class TokenInterceptor @Inject constructor( return chain.proceed(request.build()) } -} \ No newline at end of file +} diff --git a/android/data/src/main/java/com/sixkids/data/repository/TokenRepositoryImpl.kt b/android/data/src/main/java/com/sixkids/data/repository/TokenRepositoryImpl.kt index 05e6b69b..71260f0f 100644 --- a/android/data/src/main/java/com/sixkids/data/repository/TokenRepositoryImpl.kt +++ b/android/data/src/main/java/com/sixkids/data/repository/TokenRepositoryImpl.kt @@ -16,6 +16,7 @@ class TokenRepositoryImpl @Inject constructor( companion object { val ACCESS_TOKEN_KEY = stringPreferencesKey("access_token") val REFRESH_TOKEN_KEY = stringPreferencesKey("refresh_token") + val ID_TOKEN_KEY = stringPreferencesKey("id_token") } override suspend fun getAccessToken(): String = dataStore.data.map { preferences -> @@ -40,10 +41,29 @@ class TokenRepositoryImpl @Inject constructor( } } + override suspend fun saveIdToken(token: String) { + dataStore.edit { preferences -> + preferences[ID_TOKEN_KEY] = token + } + } + + override suspend fun getIdToken(): String = + dataStore.data.map { preferences -> + preferences[ID_TOKEN_KEY] ?: "" + }.first() + + override suspend fun deleteIdToken() { + dataStore.edit { preferences -> + preferences.remove(ID_TOKEN_KEY) + } + } + + override suspend fun clearTokens() { dataStore.edit { preferences -> preferences.remove(ACCESS_TOKEN_KEY) preferences.remove(REFRESH_TOKEN_KEY) + preferences.remove(ID_TOKEN_KEY) } } diff --git a/android/data/src/main/java/com/sixkids/data/repository/challenge/ChallengeRepositoryImpl.kt b/android/data/src/main/java/com/sixkids/data/repository/challenge/ChallengeRepositoryImpl.kt new file mode 100644 index 00000000..69b90ca2 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/repository/challenge/ChallengeRepositoryImpl.kt @@ -0,0 +1,81 @@ +package com.sixkids.data.repository.challenge + +import android.util.Log +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import com.sixkids.data.api.ChallengeService +import com.sixkids.data.model.response.toModel +import com.sixkids.data.repository.challenge.remote.ChallengeHistoryPagingSource +import com.sixkids.data.repository.challenge.remote.ChallengeHistoryPagingSource.Companion.DEFAULT_SIZE +import com.sixkids.data.repository.challenge.remote.ChallengeRemoteDataSourceImpl +import com.sixkids.domain.repository.ChallengeRepository +import com.sixkids.model.AcceptStatus +import com.sixkids.model.Challenge +import com.sixkids.model.ChallengeDetail +import com.sixkids.model.GroupSimple +import com.sixkids.model.RunningChallengeByStudent +import kotlinx.coroutines.flow.Flow +import java.time.LocalDateTime +import javax.inject.Inject + +class ChallengeRepositoryImpl @Inject constructor( + private val challengeService: ChallengeService, + private val challengeRemoteDataSourceImpl: ChallengeRemoteDataSourceImpl, +) : ChallengeRepository { + override suspend fun getChallengeHistory( + organizationId: Int, + memberId: Int?, + ): Flow> = + Pager( + config = PagingConfig(DEFAULT_SIZE), + pagingSourceFactory = { + ChallengeHistoryPagingSource( + challengeService, + organizationId, + memberId, + ) + } + ).flow + + override suspend fun getRunningChallenge(organizationId: Int) = + challengeRemoteDataSourceImpl.getRunningChallenges(organizationId) + + override suspend fun getRunningChallengesByStudent( + organizationId: Int + ): RunningChallengeByStudent { + val a = challengeRemoteDataSourceImpl.getRunningChallengesByStudent(organizationId) + Log.d("ttt", "getRunningChallengesByStudent: $a") + Log.d("ttt", "getRunningChallengesByStudent: ${a.toModel()}") + return a.toModel() + } + + override suspend fun createChallenge( + organizationId: Int, + title: String, + content: String, + startTime: LocalDateTime, + endTime: LocalDateTime, + reward: Int, + minCount: Int, + groups: List + ): Long = challengeRemoteDataSourceImpl.createChallenge( + organizationId, + title, + content, + startTime, + endTime, + reward, + minCount, + groups + ) + + override suspend fun getChallengeSimple(challengeId: Int): Challenge = + challengeRemoteDataSourceImpl.getChallengeSimple(challengeId) + + override suspend fun getChallengeDetail(challengeId: Long, groupId: Long?): ChallengeDetail = + challengeRemoteDataSourceImpl.getChallengeDetail(challengeId, groupId) + + override suspend fun gradingChallenge(reportId: Long, acceptStatus: AcceptStatus) = + challengeRemoteDataSourceImpl.gradingChallenge(reportId, acceptStatus) +} diff --git a/android/data/src/main/java/com/sixkids/data/repository/challenge/remote/ChallengeHistoryPagingSource.kt b/android/data/src/main/java/com/sixkids/data/repository/challenge/remote/ChallengeHistoryPagingSource.kt new file mode 100644 index 00000000..ac10c0ad --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/repository/challenge/remote/ChallengeHistoryPagingSource.kt @@ -0,0 +1,51 @@ +package com.sixkids.data.repository.challenge.remote + +import android.util.Log +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.sixkids.data.api.ChallengeService +import com.sixkids.data.model.response.toModel +import com.sixkids.model.Challenge +import javax.inject.Inject + +class ChallengeHistoryPagingSource @Inject constructor( + private val challengeService: ChallengeService, + private val organizationId: Int, + private val memberId: Int? = null, +) : PagingSource() { + override fun getRefreshKey(state: PagingState): Int? { + return state.anchorPosition?.let { anchorPosition -> + state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1) + ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1) + } + } + + override suspend fun load(params: LoadParams): LoadResult { + val page = params.key ?: 0 + return try { + + val response = challengeService.getChallengeHistory( + organizationId, + memberId, + page, + DEFAULT_SIZE + ) + val challengeHistory = response.getOrThrow().data.let { challengeResponse -> + challengeResponse.challenges.map { it.toModel(challengeResponse.totalCount) } + } + + LoadResult.Page( + data = challengeHistory, + prevKey = if (page == 0) null else page.minus(1), + nextKey = if (challengeHistory.isEmpty()) null else page.plus(1) + ) + } catch (e: Exception) { + LoadResult.Error(e) + } + } + + companion object { + const val DEFAULT_SIZE = 10 + } + +} diff --git a/android/data/src/main/java/com/sixkids/data/repository/challenge/remote/ChallengeRemoteDataSource.kt b/android/data/src/main/java/com/sixkids/data/repository/challenge/remote/ChallengeRemoteDataSource.kt new file mode 100644 index 00000000..f6868748 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/repository/challenge/remote/ChallengeRemoteDataSource.kt @@ -0,0 +1,37 @@ +package com.sixkids.data.repository.challenge.remote + +import com.sixkids.data.model.response.RunningChallengeByStudentResponse +import com.sixkids.model.AcceptStatus +import com.sixkids.model.Challenge +import com.sixkids.model.ChallengeDetail +import com.sixkids.model.GroupSimple +import com.sixkids.model.RunningChallenge +import java.time.LocalDateTime + +interface ChallengeRemoteDataSource { + + suspend fun getRunningChallenges(organizationId: Int): RunningChallenge + + suspend fun getRunningChallengesByStudent(organizationId: Int): RunningChallengeByStudentResponse + + suspend fun createChallenge( + organizationId: Int, + title: String, + content: String, + startTime: LocalDateTime, + endTime: LocalDateTime, + reward: Int, + minCount: Int, + groups: List + ): Long + + suspend fun getChallengeDetail(challengeId: Long, groupId: Long?): ChallengeDetail + + suspend fun gradingChallenge(reportId: Long, acceptStatus: AcceptStatus) + + suspend fun getChallengeSimple( + challengeId: Int + ): Challenge + + suspend fun connectSse() +} diff --git a/android/data/src/main/java/com/sixkids/data/repository/challenge/remote/ChallengeRemoteDataSourceImpl.kt b/android/data/src/main/java/com/sixkids/data/repository/challenge/remote/ChallengeRemoteDataSourceImpl.kt new file mode 100644 index 00000000..a4461fac --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/repository/challenge/remote/ChallengeRemoteDataSourceImpl.kt @@ -0,0 +1,67 @@ +package com.sixkids.data.repository.challenge.remote + +import com.sixkids.data.api.ChallengeService +import com.sixkids.data.model.request.ChallengeCreateRequest +import com.sixkids.data.model.request.GroupRequest +import com.sixkids.data.model.response.RunningChallengeByStudentResponse +import com.sixkids.data.model.response.toModel +import com.sixkids.model.AcceptStatus +import com.sixkids.model.Challenge +import com.sixkids.model.ChallengeDetail +import com.sixkids.model.GroupSimple +import java.time.LocalDateTime +import javax.inject.Inject + +class ChallengeRemoteDataSourceImpl @Inject constructor( + private val challengeService: ChallengeService, +) : ChallengeRemoteDataSource { + override suspend fun getRunningChallenges(organizationId: Int) = + challengeService.getRunningChallenge(organizationId).getOrThrow().data.toModel() + + override suspend fun getRunningChallengesByStudent( + organizationId: Int + ): RunningChallengeByStudentResponse = + challengeService.getRunningChallengeByStudent(organizationId).getOrThrow().data + + + override suspend fun createChallenge( + organizationId: Int, + title: String, + content: String, + startTime: LocalDateTime, + endTime: LocalDateTime, + reward: Int, + minCount: Int, + groups: List, + ): Long = challengeService.createChallenge( + ChallengeCreateRequest( + organizationId = organizationId, + title = title, + content = content, + startTime = startTime, + endTime = endTime, + reward = reward, + minCount = minCount, + groups = groups.map { + GroupRequest( + headCount = it.headCount, + leaderId = it.leaderId, + students = it.students + ) + } + ) + ).getOrThrow().data + + override suspend fun getChallengeSimple(challengeId: Int): Challenge = + challengeService.getChallengeSimple(challengeId).getOrThrow().data.toModel() + + override suspend fun connectSse() { + + } + + override suspend fun getChallengeDetail(challengeId: Long, groupId: Long?): ChallengeDetail = + challengeService.getChallengeDetail(challengeId, groupId).getOrThrow().data.toModel() + + override suspend fun gradingChallenge(reportId: Long, acceptStatus: AcceptStatus) = + challengeService.gradingChallenge(reportId, acceptStatus.name).getOrThrow().data +} diff --git a/android/data/src/main/java/com/sixkids/data/repository/chatting/ChattingRepositoryImpl.kt b/android/data/src/main/java/com/sixkids/data/repository/chatting/ChattingRepositoryImpl.kt new file mode 100644 index 00000000..2f12b747 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/repository/chatting/ChattingRepositoryImpl.kt @@ -0,0 +1,29 @@ +package com.sixkids.data.repository.chatting + +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import com.sixkids.data.api.ChattingService +import com.sixkids.data.repository.chatting.remote.ChatHistoryPagingSource +import com.sixkids.data.repository.chatting.remote.ChattingRemoteDataSource +import com.sixkids.domain.repository.ChattingRepository +import com.sixkids.model.Chat +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class ChattingRepositoryImpl @Inject constructor( + private val chattingService: ChattingService, + private val chattingRemoteDataSource: ChattingRemoteDataSource +) : ChattingRepository { + override suspend fun getChattingList(roomId: Long): Flow> = + Pager( + config = PagingConfig(ChatHistoryPagingSource.DEFAULT_SIZE), + pagingSourceFactory = { + ChatHistoryPagingSource( + chattingService, + roomId + ) + } + ).flow + +} \ No newline at end of file diff --git a/android/data/src/main/java/com/sixkids/data/repository/chatting/remote/ChatHistoryPagingSource.kt b/android/data/src/main/java/com/sixkids/data/repository/chatting/remote/ChatHistoryPagingSource.kt new file mode 100644 index 00000000..0bc535f5 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/repository/chatting/remote/ChatHistoryPagingSource.kt @@ -0,0 +1,49 @@ +package com.sixkids.data.repository.chatting.remote + +import android.util.Log +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.sixkids.data.api.ChattingService +import com.sixkids.data.model.response.toModel +import com.sixkids.data.repository.challenge.remote.ChallengeHistoryPagingSource +import com.sixkids.model.Chat +import javax.inject.Inject + +private const val TAG = "D107" +class ChatHistoryPagingSource @Inject constructor( + private val chattingService: ChattingService, + private val roomId: Long, +) : PagingSource() { + override fun getRefreshKey(state: PagingState): Int? { + return state.anchorPosition?.let { anchorPosition -> + state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1) + ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1) + } + } + + override suspend fun load(params: LoadParams): LoadResult { + val page = params.key ?: 0 + return try { + val response = chattingService.getChatList( + roomId, + page, + DEFAULT_SIZE + ) + val chatHistory = response.getOrThrow().data.messages.map { it.toModel() } + Log.d(TAG, "load: ${page} -> ${chatHistory.map { it.content }}") + LoadResult.Page( + data = chatHistory, + prevKey = if (chatHistory.isEmpty()) null else page.plus(1), + nextKey = null + ) + } catch (e: Exception) { + Log.d(TAG, "load: ${e.message}") + LoadResult.Error(e) + } + } + + companion object { + const val DEFAULT_SIZE = 500 + } + +} \ No newline at end of file diff --git a/android/data/src/main/java/com/sixkids/data/repository/chatting/remote/ChattingRemoteDataSource.kt b/android/data/src/main/java/com/sixkids/data/repository/chatting/remote/ChattingRemoteDataSource.kt new file mode 100644 index 00000000..43e7ed6f --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/repository/chatting/remote/ChattingRemoteDataSource.kt @@ -0,0 +1,6 @@ +package com.sixkids.data.repository.chatting.remote + +import com.sixkids.model.Chat + +interface ChattingRemoteDataSource { +} \ No newline at end of file diff --git a/android/data/src/main/java/com/sixkids/data/repository/chatting/remote/ChattingRemoteDataSourceImpl.kt b/android/data/src/main/java/com/sixkids/data/repository/chatting/remote/ChattingRemoteDataSourceImpl.kt new file mode 100644 index 00000000..27729d26 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/repository/chatting/remote/ChattingRemoteDataSourceImpl.kt @@ -0,0 +1,10 @@ +package com.sixkids.data.repository.chatting.remote + +import com.sixkids.data.api.ChattingService +import javax.inject.Inject + +class ChattingRemoteDataSourceImpl @Inject constructor( + private val chattingService: ChattingService +) : ChattingRemoteDataSource{ + +} \ No newline at end of file diff --git a/android/data/src/main/java/com/sixkids/data/repository/chattingfilter/ChattingFilterRepositoryImpl.kt b/android/data/src/main/java/com/sixkids/data/repository/chattingfilter/ChattingFilterRepositoryImpl.kt new file mode 100644 index 00000000..6fc1b9ac --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/repository/chattingfilter/ChattingFilterRepositoryImpl.kt @@ -0,0 +1,54 @@ +package com.sixkids.data.repository.chattingfilter + +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import androidx.paging.map +import com.sixkids.data.api.ChatFilterService +import com.sixkids.data.model.response.toModel +import com.sixkids.data.repository.chattingfilter.remote.ChattingFilterPagingSource +import com.sixkids.data.repository.chattingfilter.remote.ChattingFilterRemoteDataSource +import com.sixkids.domain.repository.ChattingFilterRepository +import com.sixkids.model.ChatFilterWord +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class ChattingFilterRepositoryImpl @Inject constructor( + private val chattingFilterRemoteDataSource: ChattingFilterRemoteDataSource, + private val chatFilterService: ChatFilterService +) : ChattingFilterRepository { + override suspend fun getChattingFilters( + organizationId: Int + ): Flow> { + return Pager( + config = PagingConfig(ChattingFilterPagingSource.DEFAULT_SIZE), + pagingSourceFactory = { + ChattingFilterPagingSource( + chatFilterService, + organizationId + ) + } + ).flow.map {pagingData -> + pagingData.map { + chattingFilterResponse -> chattingFilterResponse.toModel() + } + } + } + + override suspend fun deleteChatFilter(id: Long): Boolean { + return chattingFilterRemoteDataSource.deleteChatFilter(id) + } + + override suspend fun createChatFilter(organizationId: Long, badWord: String): Long { + return chattingFilterRemoteDataSource.createChatFilter(organizationId, badWord) + } + + override suspend fun updateChatFilter( + organizationId: Long, + id: Long, + badWord: String + ): Boolean { + return chattingFilterRemoteDataSource.updateChatFilter(organizationId, id, badWord) + } +} \ No newline at end of file diff --git a/android/data/src/main/java/com/sixkids/data/repository/chattingfilter/remote/ChattingFilterPagingSource.kt b/android/data/src/main/java/com/sixkids/data/repository/chattingfilter/remote/ChattingFilterPagingSource.kt new file mode 100644 index 00000000..2b11222e --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/repository/chattingfilter/remote/ChattingFilterPagingSource.kt @@ -0,0 +1,44 @@ +package com.sixkids.data.repository.chattingfilter.remote + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.sixkids.data.api.ChatFilterService +import com.sixkids.data.api.ChattingService +import com.sixkids.data.model.response.ChattingFilterListResponse +import com.sixkids.data.model.response.ChattingFilterResponse +import javax.inject.Inject + +class ChattingFilterPagingSource @Inject constructor( + private val chatFilterService: ChatFilterService, + private val organizationId: Int, + ): PagingSource() { + override fun getRefreshKey(state: PagingState): Int? { + return state.anchorPosition?.let { anchorPosition -> + state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1) + ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1) + } + } + + override suspend fun load(params: LoadParams): LoadResult { + val page = params.key ?: 0 + return try { + val response = chatFilterService.getChatFilters( + organizationId, + page, + DEFAULT_SIZE + ).getOrThrow().data.words + + LoadResult.Page( + data = response, + prevKey = if (page == 0) null else page.minus(1), + nextKey = if (response.isEmpty()) null else page.plus(1) + ) + } catch (e: Exception) { + LoadResult.Error(e) + } + } + + companion object { + const val DEFAULT_SIZE = 30 + } +} \ No newline at end of file diff --git a/android/data/src/main/java/com/sixkids/data/repository/chattingfilter/remote/ChattingFilterRemoteDataSource.kt b/android/data/src/main/java/com/sixkids/data/repository/chattingfilter/remote/ChattingFilterRemoteDataSource.kt new file mode 100644 index 00000000..f0d5c449 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/repository/chattingfilter/remote/ChattingFilterRemoteDataSource.kt @@ -0,0 +1,22 @@ +package com.sixkids.data.repository.chattingfilter.remote + +import com.sixkids.data.model.response.ChattingFilterListResponse + +interface ChattingFilterRemoteDataSource { + + suspend fun deleteChatFilter( + id: Long + ): Boolean + + suspend fun createChatFilter( + organizationId: Long, + badWord: String, + ): Long + + suspend fun updateChatFilter( + organizationId: Long, + id: Long, + badWord: String, + ): Boolean + +} \ No newline at end of file diff --git a/android/data/src/main/java/com/sixkids/data/repository/chattingfilter/remote/ChattingFilterRemoteDataSourceImpl.kt b/android/data/src/main/java/com/sixkids/data/repository/chattingfilter/remote/ChattingFilterRemoteDataSourceImpl.kt new file mode 100644 index 00000000..74102695 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/repository/chattingfilter/remote/ChattingFilterRemoteDataSourceImpl.kt @@ -0,0 +1,33 @@ +package com.sixkids.data.repository.chattingfilter.remote + +import com.sixkids.data.api.ChatFilterService +import com.sixkids.data.model.request.ChatFilterRequest +import com.sixkids.data.model.response.ChattingFilterListResponse +import javax.inject.Inject + +class ChattingFilterRemoteDataSourceImpl @Inject constructor( + private val chattingFilterService: ChatFilterService +) : ChattingFilterRemoteDataSource { + + override suspend fun deleteChatFilter(id: Long): Boolean { + return chattingFilterService.deleteChatFilter(id).getOrThrow().data + } + + override suspend fun createChatFilter(organizationId: Long, badWord: String): Long { + return chattingFilterService.createChatFilter(organizationId, ChatFilterRequest(badWord)) + .getOrThrow().data + } + + override suspend fun updateChatFilter( + organizationId: Long, + id: Long, + badWord: String + ): Boolean { + return chattingFilterService.updateChatFilter( + id, + organizationId, + ChatFilterRequest(badWord) + ).getOrThrow().data + } + +} \ No newline at end of file diff --git a/android/data/src/main/java/com/sixkids/data/repository/comment/CommentRepositoryImpl.kt b/android/data/src/main/java/com/sixkids/data/repository/comment/CommentRepositoryImpl.kt new file mode 100644 index 00000000..340c4d8f --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/repository/comment/CommentRepositoryImpl.kt @@ -0,0 +1,25 @@ +package com.sixkids.data.repository.comment + +import com.sixkids.data.repository.comment.remote.CommentRemoteDataSource +import com.sixkids.domain.repository.CommentRepository +import javax.inject.Inject + +class CommentRepositoryImpl @Inject constructor( + private val commentRemoteDataSource: CommentRemoteDataSource +) : CommentRepository { + override suspend fun createComment(postId: Long, content: String, parentId: Long): Long { + return commentRemoteDataSource.createComment(postId, content, parentId) + } + + override suspend fun deleteComment(id: Long): Boolean { + return commentRemoteDataSource.deleteComment(id) + } + + override suspend fun updateComment(id: Long, content: String): Long { + return commentRemoteDataSource.updateComment(id, content) + } + + override suspend fun reportComment(id: Long): Boolean { + return commentRemoteDataSource.reportComment(id) + } +} \ No newline at end of file diff --git a/android/data/src/main/java/com/sixkids/data/repository/comment/remote/CommentRemoteDataSource.kt b/android/data/src/main/java/com/sixkids/data/repository/comment/remote/CommentRemoteDataSource.kt new file mode 100644 index 00000000..3e2276ce --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/repository/comment/remote/CommentRemoteDataSource.kt @@ -0,0 +1,11 @@ +package com.sixkids.data.repository.comment.remote + +interface CommentRemoteDataSource { + suspend fun createComment(postId: Long, content: String, parentId: Long): Long + + suspend fun deleteComment(id: Long): Boolean + + suspend fun updateComment(id: Long, content: String): Long + + suspend fun reportComment(id: Long): Boolean +} \ No newline at end of file diff --git a/android/data/src/main/java/com/sixkids/data/repository/comment/remote/CommentRemoteDataSourceImpl.kt b/android/data/src/main/java/com/sixkids/data/repository/comment/remote/CommentRemoteDataSourceImpl.kt new file mode 100644 index 00000000..28387f5d --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/repository/comment/remote/CommentRemoteDataSourceImpl.kt @@ -0,0 +1,26 @@ +package com.sixkids.data.repository.comment.remote + +import com.sixkids.data.api.CommentService +import com.sixkids.data.model.request.NewCommentRequest +import com.sixkids.data.model.request.UpdateCommentRequest +import javax.inject.Inject + +class CommentRemoteDataSourceImpl @Inject constructor( + private val commentService: CommentService +) : CommentRemoteDataSource{ + override suspend fun createComment(postId: Long, content: String, parentId: Long): Long { + return commentService.createComment(NewCommentRequest(postId, content, parentId)).getOrThrow().data + } + + override suspend fun deleteComment(id: Long): Boolean { + return commentService.deleteComment(id).getOrThrow().data + } + + override suspend fun updateComment(id: Long, content: String): Long { + return commentService.updateComment(id, UpdateCommentRequest(id, content)).getOrThrow().data + } + + override suspend fun reportComment(id: Long): Boolean { + return commentService.reportComment(id).getOrThrow().data + } +} \ No newline at end of file diff --git a/android/data/src/main/java/com/sixkids/data/repository/group/GroupRepositoryImpl.kt b/android/data/src/main/java/com/sixkids/data/repository/group/GroupRepositoryImpl.kt new file mode 100644 index 00000000..953ee2b9 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/repository/group/GroupRepositoryImpl.kt @@ -0,0 +1,32 @@ +package com.sixkids.data.repository.group + +import com.sixkids.data.model.response.toModel +import com.sixkids.data.repository.group.remote.GroupDataSource +import com.sixkids.domain.repository.GroupRepository +import com.sixkids.model.MatchingRoom +import javax.inject.Inject + +class GroupRepositoryImpl @Inject constructor( + private val groupDataSource: GroupDataSource +) : GroupRepository { + override suspend fun createMatchingRoom(challengeId: Long): MatchingRoom = + groupDataSource.createMatchingRoom(challengeId).toModel() + + override suspend fun inviteFriend(key: String, memberId: Long) = + groupDataSource.inviteFriend(key, memberId) + + override suspend fun deportFriend(key: String, memberId: Long) = + groupDataSource.deportFriend(key, memberId) + + override suspend fun joinGroup(key: String, joinStatus: Boolean) = + groupDataSource.joinGroup(key, joinStatus) + + override suspend fun createGroup(key: String): Long = groupDataSource.createGroup(key) + + override suspend fun getMatchingGroup( + organizationId: Long, + minCount: Int, + matchingType: String, + members: List + ) = groupDataSource.getMatchingGroup(organizationId, minCount, matchingType, members).map { it.toModel() } +} diff --git a/android/data/src/main/java/com/sixkids/data/repository/group/remote/GroupDataSource.kt b/android/data/src/main/java/com/sixkids/data/repository/group/remote/GroupDataSource.kt new file mode 100644 index 00000000..c33e810d --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/repository/group/remote/GroupDataSource.kt @@ -0,0 +1,23 @@ +package com.sixkids.data.repository.group.remote + +import com.sixkids.data.model.response.GroupMatchingRoomResponse +import com.sixkids.data.model.response.GroupMatchingSuccessResponse + +interface GroupDataSource { + suspend fun createMatchingRoom(challengeId: Long): GroupMatchingRoomResponse + + suspend fun inviteFriend(key: String, memberId: Long) + + suspend fun deportFriend(key: String, memberId: Long) + + suspend fun joinGroup(key: String, joinStatus: Boolean) + + suspend fun createGroup(key: String): Long + + suspend fun getMatchingGroup( + organizationId: Long, + minCount: Int, + matchingType: String, + members: List + ): List +} diff --git a/android/data/src/main/java/com/sixkids/data/repository/group/remote/GroupDataSourceImpl.kt b/android/data/src/main/java/com/sixkids/data/repository/group/remote/GroupDataSourceImpl.kt new file mode 100644 index 00000000..d5d05239 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/repository/group/remote/GroupDataSourceImpl.kt @@ -0,0 +1,33 @@ +package com.sixkids.data.repository.group.remote + +import com.sixkids.data.api.GroupService +import com.sixkids.data.model.response.GroupMatchingRoomResponse +import com.sixkids.data.model.response.GroupMatchingSuccessResponse +import javax.inject.Inject + +class GroupDataSourceImpl @Inject constructor( + private val groupService: GroupService +) : GroupDataSource { + override suspend fun createMatchingRoom(challengeId: Long): GroupMatchingRoomResponse = + groupService.createMatchingRoom(challengeId).getOrThrow().data + + override suspend fun inviteFriend(key: String, memberId: Long) = + groupService.inviteFriend(key, memberId).getOrThrow().data + + override suspend fun deportFriend(key: String, memberId: Long) = + groupService.deportFriend(key, memberId).getOrThrow().data + + override suspend fun joinGroup(key: String, joinStatus: Boolean) = + groupService.joinGroup(key, joinStatus).getOrThrow().data + + override suspend fun createGroup(key: String): Long = + groupService.createGroup(key).getOrThrow().data + + override suspend fun getMatchingGroup( + organizationId: Long, + minCount: Int, + matchingType: String, + members: List + ): List = + groupService.getMatchingGroup(organizationId, minCount, matchingType, members).getOrThrow().data +} diff --git a/android/data/src/main/java/com/sixkids/data/repository/organization/OrganizationRepositoryImpl.kt b/android/data/src/main/java/com/sixkids/data/repository/organization/OrganizationRepositoryImpl.kt new file mode 100644 index 00000000..dd6a6e75 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/repository/organization/OrganizationRepositoryImpl.kt @@ -0,0 +1,91 @@ +package com.sixkids.data.repository.organization + +import com.sixkids.data.model.response.toModel +import com.sixkids.data.repository.organization.local.OrganizationLocalDataSource +import com.sixkids.data.repository.organization.remote.OrganizationRemoteDataSource +import com.sixkids.domain.repository.OrganizationRepository +import com.sixkids.model.ClassSummary +import com.sixkids.model.MemberDetail +import com.sixkids.model.MemberRankItem +import com.sixkids.model.MemberSimple +import com.sixkids.model.MemberSimpleWithScore +import com.sixkids.model.Organization +import com.sixkids.model.StudentRelation +import javax.inject.Inject + +class OrganizationRepositoryImpl @Inject constructor( + private val organizationRemoteDataSource: OrganizationRemoteDataSource, + private val organizationLocalDataSource: OrganizationLocalDataSource +) : OrganizationRepository { + override suspend fun getClassList(): List { + return organizationRemoteDataSource.getClassList() + } + + override suspend fun saveSelectedOrganizationId(organizationId: Int) { + organizationLocalDataSource.saveSelectedOrganizationId(organizationId) + } + + override suspend fun getSelectedOrganizationId(): Int { + return organizationLocalDataSource.getSelectedOrganizationId() + } + + override suspend fun newOrganization(name: String): Long { + return organizationRemoteDataSource.newOrganization(name) + } + + override suspend fun joinOrganization(orgId: Int, code: String): Long { + return organizationRemoteDataSource.joinOrganization(orgId, code) + } + + override suspend fun getOrganizationSummary(organizationId: Int): ClassSummary { + return organizationRemoteDataSource.getOrganizationSummary(organizationId) + } + + override suspend fun updateOrganization(organizationId: Int, name: String): String { + return organizationRemoteDataSource.updateOrganization(organizationId, name) + } + + override suspend fun getOrganizationInviteCode(organizationId: Int): String { + return organizationRemoteDataSource.getOrganizationInviteCode(organizationId) + } + + override suspend fun saveSelectedOrganizationName(organizationName: String) { + organizationLocalDataSource.saveSelectedOrganizationName(organizationName) + } + + override suspend fun loadSelectedOrganizationName(): String { + return organizationLocalDataSource.getSelectedOrganizationName() + } + + override suspend fun getOrganizationMembers(orgId: Int): List { + return organizationRemoteDataSource.getOrganizationMembers(orgId) + } + + override suspend fun getStudentDetail(orgId: Long, studentId: Long): MemberDetail { + return organizationRemoteDataSource.getStudentDetail(orgId, studentId) + } + + override suspend fun getStudentRelation( + orgId: Long, + studentId: Long, + limit: Int? + ): List { + return organizationRemoteDataSource.getStudentRelation(orgId, studentId, limit) + } + + override suspend fun getStudentRelationDetail( + orgId: Long, + sourceStudentId: Long, + targetStudentId: Long + ): StudentRelation { + return organizationRemoteDataSource.getStudentRelationDetail(orgId, sourceStudentId, targetStudentId) + } + + override suspend fun getOrganizationRank(orgId: Int): List { + return organizationRemoteDataSource.getOrganizationRank(orgId).toModel() + } + + override suspend fun tagGreeting(orgId: Long, memberId: Long): Int { + return organizationRemoteDataSource.tagGreeting(orgId, memberId) + } +} diff --git a/android/data/src/main/java/com/sixkids/data/repository/organization/local/OrganizationLocalDataSource.kt b/android/data/src/main/java/com/sixkids/data/repository/organization/local/OrganizationLocalDataSource.kt new file mode 100644 index 00000000..2298ee06 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/repository/organization/local/OrganizationLocalDataSource.kt @@ -0,0 +1,8 @@ +package com.sixkids.data.repository.organization.local + +interface OrganizationLocalDataSource { + suspend fun getSelectedOrganizationId(): Int + suspend fun saveSelectedOrganizationId(organizationId: Int) + suspend fun getSelectedOrganizationName(): String + suspend fun saveSelectedOrganizationName(organizationName: String) +} \ No newline at end of file diff --git a/android/data/src/main/java/com/sixkids/data/repository/organization/local/OrganizationLocalDataSourceImpl.kt b/android/data/src/main/java/com/sixkids/data/repository/organization/local/OrganizationLocalDataSourceImpl.kt new file mode 100644 index 00000000..62cff7b0 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/repository/organization/local/OrganizationLocalDataSourceImpl.kt @@ -0,0 +1,44 @@ +package com.sixkids.data.repository.organization.local + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class OrganizationLocalDataSourceImpl @Inject constructor( + private val dataStore: DataStore +) : OrganizationLocalDataSource { + override suspend fun getSelectedOrganizationId(): Int { + return dataStore.data.map { preference -> + preference[SELECTED_ORGANIZATION_ID_KEY] ?: 0 + }.first() + } + + override suspend fun saveSelectedOrganizationId(organizationId: Int) { + dataStore.edit { preference -> + preference[SELECTED_ORGANIZATION_ID_KEY] = organizationId + } + } + + override suspend fun getSelectedOrganizationName(): String { + return dataStore.data.map { preference -> + preference[SELECTED_ORGANIZATION_NAME_KEY] ?: "" + }.first() + } + + override suspend fun saveSelectedOrganizationName(organizationName: String) { + dataStore.edit { preference -> + preference[SELECTED_ORGANIZATION_NAME_KEY] = organizationName + } + } + + companion object{ + private val SELECTED_ORGANIZATION_ID_KEY = intPreferencesKey("selected_organization_id") + private val SELECTED_ORGANIZATION_NAME_KEY = stringPreferencesKey("selected_organization_name") + } + +} \ No newline at end of file diff --git a/android/data/src/main/java/com/sixkids/data/repository/organization/remote/OrganizationRemoteDataSource.kt b/android/data/src/main/java/com/sixkids/data/repository/organization/remote/OrganizationRemoteDataSource.kt new file mode 100644 index 00000000..a25d5bc3 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/repository/organization/remote/OrganizationRemoteDataSource.kt @@ -0,0 +1,36 @@ +package com.sixkids.data.repository.organization.remote + +import com.sixkids.model.ClassSummary +import com.sixkids.data.model.response.ClassSummaryResponse +import com.sixkids.data.model.response.RankResponse +import com.sixkids.model.MemberDetail +import com.sixkids.model.MemberSimple +import com.sixkids.model.MemberSimpleWithScore +import com.sixkids.model.Organization +import com.sixkids.model.StudentRelation + +interface OrganizationRemoteDataSource { + suspend fun getClassList(): List + + suspend fun newOrganization(name: String): Long + + suspend fun joinOrganization(orgId: Int, code: String): Long + + suspend fun getOrganizationSummary(organizationId: Int): ClassSummary + + suspend fun updateOrganization(organizationId: Int, name: String): String + + suspend fun getOrganizationInviteCode(organizationId: Int): String + + suspend fun getOrganizationMembers(orgId: Int): List + + suspend fun getStudentDetail(orgId: Long, studentId: Long): MemberDetail + + suspend fun getStudentRelation(orgId: Long, studentId: Long, limit: Int?): List + + suspend fun getStudentRelationDetail(orgId: Long, sourceStudentId: Long, targetStudentId: Long): StudentRelation + + suspend fun getOrganizationRank(orgId: Int): List + + suspend fun tagGreeting(orgId: Long, memberId: Long): Int +} \ No newline at end of file diff --git a/android/data/src/main/java/com/sixkids/data/repository/organization/remote/OrganizationRemoteDataSourceImpl.kt b/android/data/src/main/java/com/sixkids/data/repository/organization/remote/OrganizationRemoteDataSourceImpl.kt new file mode 100644 index 00000000..084e9b54 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/repository/organization/remote/OrganizationRemoteDataSourceImpl.kt @@ -0,0 +1,80 @@ +package com.sixkids.data.repository.organization.remote + +import com.sixkids.data.api.MemberOrgService +import com.sixkids.data.api.OrganizationService +import com.sixkids.data.model.request.GreetingRequest +import com.sixkids.data.model.request.JoinOrganizationRequest +import com.sixkids.data.model.request.NewOrganizationRequest +import com.sixkids.data.model.response.ClassSummaryResponse +import com.sixkids.data.model.response.RankResponse +import com.sixkids.data.model.response.toModel +import com.sixkids.model.ClassSummary +import com.sixkids.model.MemberDetail +import com.sixkids.model.MemberSimple +import com.sixkids.model.MemberSimpleWithScore +import com.sixkids.model.Organization +import com.sixkids.model.StudentRelation +import javax.inject.Inject + +class OrganizationRemoteDataSourceImpl @Inject constructor( + private val organizationService: OrganizationService, + private val memberOrgService: MemberOrgService +) : OrganizationRemoteDataSource { + override suspend fun getClassList(): List { + return organizationService.getOrganizationList().getOrThrow().data.map { it.toModel() } + } + + override suspend fun newOrganization(name: String): Long { + return organizationService.newOrganization(NewOrganizationRequest(name)).getOrThrow().data + } + + override suspend fun joinOrganization(orgId: Int, code: String): Long { + return organizationService.joinOrganization(orgId, JoinOrganizationRequest(code)) + .getOrThrow().data + } + + override suspend fun getOrganizationSummary(organizationId: Int): ClassSummary { + return organizationService.getOrganizationSummary(organizationId).getOrThrow().data.toModel() + } + + override suspend fun updateOrganization(organizationId: Int, name: String): String { + return organizationService.updateOrganization(organizationId, NewOrganizationRequest(name)) + .getOrThrow().data.name + } + + override suspend fun getOrganizationInviteCode(organizationId: Int): String { + return organizationService.getOrganizationInviteCode(organizationId).getOrThrow().data.code + } + + override suspend fun getOrganizationMembers(orgId: Int): List { + return organizationService.getOrganizationMembers(orgId).getOrThrow().data.map { it.toModel() } + } + + override suspend fun getStudentDetail(orgId: Long, studentId: Long): MemberDetail { + return memberOrgService.getMemberDetail(orgId, studentId).getOrThrow().data.toModel() + } + + override suspend fun getStudentRelation( + orgId: Long, + studentId: Long, + limit: Int? + ): List { + return memberOrgService.getRelationSimple(orgId, studentId.toInt(), limit).getOrThrow().data.map { it.toModel() } + } + + override suspend fun getStudentRelationDetail( + orgId: Long, + sourceStudentId: Long, + targetStudentId: Long + ): StudentRelation { + return memberOrgService.getRelationDetail(orgId, sourceStudentId.toInt(), targetStudentId.toInt()).getOrThrow().data.toModel() + } + + override suspend fun getOrganizationRank(orgId: Int): List { + return organizationService.getOrganizationRank(orgId).getOrThrow().data + } + + override suspend fun tagGreeting(orgId: Long, memberId: Long): Int { + return memberOrgService.tagGreeting(GreetingRequest(orgId, memberId)).getOrThrow().data + } +} \ No newline at end of file diff --git a/android/data/src/main/java/com/sixkids/data/repository/post/PostRepositoryImpl.kt b/android/data/src/main/java/com/sixkids/data/repository/post/PostRepositoryImpl.kt new file mode 100644 index 00000000..24fcf2fd --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/repository/post/PostRepositoryImpl.kt @@ -0,0 +1,92 @@ +package com.sixkids.data.repository.post + +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import androidx.paging.map +import com.sixkids.data.api.PostService +import com.sixkids.data.model.response.toModel +import com.sixkids.data.repository.post.remote.PostListPagingSource +import com.sixkids.data.repository.post.remote.PostRemoteDataSource +import com.sixkids.domain.repository.PostRepository +import com.sixkids.model.Post +import com.sixkids.model.PostDetail +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import java.io.File +import javax.inject.Inject + +class PostRepositoryImpl @Inject constructor( + private val postRemoteDataSource: PostRemoteDataSource, + private val postService: PostService +) : PostRepository { + override suspend fun getPosts( + organizationId: Int, + memberId: Int?, + postCategory: String + ): Flow> { + return Pager( + config = PagingConfig(PostListPagingSource.DEFAULT_SIZE), + pagingSourceFactory = { + PostListPagingSource( + postService, + organizationId, + memberId, + postCategory + ) + } + ).flow.map { pagingData -> + pagingData.map { + postResponse -> postResponse.toModel() + } + } + } + + override suspend fun createPost( + organizationId: Long, + title: String, + content: String, + secretStatus: Boolean, + postCategory: String, + file: File? + ): Long { + return postRemoteDataSource.createPost( + organizationId, + title, + content, + secretStatus, + postCategory, + file + ) + } + + override suspend fun getPostDetail(postId: Long): PostDetail { + return postRemoteDataSource.getPostDetail(postId).toModel() + } + + override suspend fun deletePost(postId: Long): Boolean { + return postRemoteDataSource.deletePost(postId) + } + + override suspend fun updatePost( + postId: Long, + title: String, + content: String, + secretStatus: Boolean, + postCategory: String, + file: File? + ): Long { + return postRemoteDataSource.updatePost( + postId, + title, + content, + secretStatus, + postCategory, + file + ) + } + + override suspend fun reportPost(postId: Long): Boolean { + return postRemoteDataSource.reportPost(postId) + } +} \ No newline at end of file diff --git a/android/data/src/main/java/com/sixkids/data/repository/post/remote/PostListPagingSource.kt b/android/data/src/main/java/com/sixkids/data/repository/post/remote/PostListPagingSource.kt new file mode 100644 index 00000000..5edd409d --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/repository/post/remote/PostListPagingSource.kt @@ -0,0 +1,46 @@ +package com.sixkids.data.repository.post.remote + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.sixkids.data.api.PostService +import com.sixkids.data.model.response.PostResponse +import javax.inject.Inject + +class PostListPagingSource @Inject constructor( + private val postService: PostService, + private val organizationId: Int, + private val memberId: Int? = null, + private val category: String, +) : PagingSource() { + override fun getRefreshKey(state: PagingState): Int? { + return state.anchorPosition?.let { anchorPosition -> + state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1) + ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1) + } + } + + override suspend fun load(params: LoadParams): LoadResult { + val page = params.key ?: 0 + return try { + val response = postService.getPosts( + organizationId, + memberId, + page, + DEFAULT_SIZE, + category + ).getOrThrow().data.posts + + LoadResult.Page( + data = response, + prevKey = if (page == 0) null else page.minus(1), + nextKey = if (response.isEmpty()) null else page.plus(1) + ) + } catch (e: Exception) { + LoadResult.Error(e) + } + } + + companion object { + const val DEFAULT_SIZE = 10 + } +} \ No newline at end of file diff --git a/android/data/src/main/java/com/sixkids/data/repository/post/remote/PostRemoteDataSource.kt b/android/data/src/main/java/com/sixkids/data/repository/post/remote/PostRemoteDataSource.kt new file mode 100644 index 00000000..1aeed962 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/repository/post/remote/PostRemoteDataSource.kt @@ -0,0 +1,38 @@ +package com.sixkids.data.repository.post.remote + +import com.sixkids.data.model.response.PostDetailResponse +import java.io.File + +interface PostRemoteDataSource { + + suspend fun createPost( + organizationId: Long, + title: String, + content: String, + secretStatus: Boolean, + postCategory: String, + file: File?, + ): Long + + suspend fun getPostDetail( + postId: Long, + ): PostDetailResponse + + suspend fun deletePost( + postId: Long, + ): Boolean + + suspend fun updatePost( + postId: Long, + title: String, + content: String, + secretStatus: Boolean, + postCategory: String, + file: File?, + ): Long + + suspend fun reportPost( + postId: Long, + ): Boolean + +} \ No newline at end of file diff --git a/android/data/src/main/java/com/sixkids/data/repository/post/remote/PostRemoteDataSourceImpl.kt b/android/data/src/main/java/com/sixkids/data/repository/post/remote/PostRemoteDataSourceImpl.kt new file mode 100644 index 00000000..f276385b --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/repository/post/remote/PostRemoteDataSourceImpl.kt @@ -0,0 +1,72 @@ +package com.sixkids.data.repository.post.remote + +import com.sixkids.data.api.PostService +import com.sixkids.data.model.request.NewPostRequest +import com.sixkids.data.model.response.PostDetailResponse +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.asRequestBody +import java.io.File +import javax.inject.Inject + +class PostRemoteDataSourceImpl @Inject constructor( + private val postService: PostService, +): PostRemoteDataSource { + override suspend fun createPost( + organizationId: Long, + title: String, + content: String, + secretStatus: Boolean, + postCategory: String, + file: File? + ): Long { + val image = file?.asRequestBody("image/jpg".toMediaTypeOrNull()) + val multipartImage = image?.let { + MultipartBody.Part.createFormData("file", file.name, it) + } + + val request = NewPostRequest( + title = title, + content = content, + secretStatus = secretStatus, + postCategory = postCategory, + ) + + return postService.createPost(organizationId, request, multipartImage).getOrThrow().data + } + + override suspend fun getPostDetail(postId: Long): PostDetailResponse { + return postService.getPostDetail(postId).getOrThrow().data + } + + override suspend fun deletePost(postId: Long): Boolean { + return postService.deletePost(postId).getOrThrow().data + } + + override suspend fun updatePost( + postId: Long, + title: String, + content: String, + secretStatus: Boolean, + postCategory: String, + file: File? + ): Long { + val image = file?.asRequestBody("image/jpg".toMediaTypeOrNull()) + val multipartImage = image?.let { + MultipartBody.Part.createFormData("file", file.name, it) + } + + val request = NewPostRequest( + title = title, + content = content, + secretStatus = secretStatus, + postCategory = postCategory, + ) + + return postService.updatePost(postId, request, multipartImage).getOrThrow().data + } + + override suspend fun reportPost(postId: Long): Boolean { + return postService.reportPost(postId).getOrThrow().data + } +} \ No newline at end of file diff --git a/android/data/src/main/java/com/sixkids/data/repository/relay/RelayRepositoryImpl.kt b/android/data/src/main/java/com/sixkids/data/repository/relay/RelayRepositoryImpl.kt new file mode 100644 index 00000000..2db214f0 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/repository/relay/RelayRepositoryImpl.kt @@ -0,0 +1,67 @@ +package com.sixkids.data.repository.relay + +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import com.sixkids.data.api.RelayService +import com.sixkids.data.repository.relay.remote.RelayHistoryPagingSource +import com.sixkids.data.repository.relay.remote.RelayRemoteDataSource +import com.sixkids.domain.repository.RelayRepository +import com.sixkids.model.Relay +import com.sixkids.model.RelayDetail +import com.sixkids.model.RelayReceive +import com.sixkids.model.RelaySend +import com.sixkids.model.RunningRelay +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class RelayRepositoryImpl @Inject constructor( + private val relayService: RelayService, + private val relayRemoteDataSource: RelayRemoteDataSource +) : RelayRepository{ + + override suspend fun getRelayHistory( + organizationId: Int, + memberId: Int?, + ): Flow> = + Pager( + config = PagingConfig(RelayHistoryPagingSource.DEFAULT_SIZE), + pagingSourceFactory = { + RelayHistoryPagingSource( + relayService, + organizationId, + memberId, + ) + } + ).flow + + override suspend fun getRunningRelay(organizationId: Long): RunningRelay { + return relayRemoteDataSource.getRunningRelay(organizationId) + } + + override suspend fun getRelayDetail(relayId: Long): RelayDetail { + return relayRemoteDataSource.getRelayDetail(relayId) + } + + override suspend fun createRelay(organizationId: Int, question: String): Long { + return relayRemoteDataSource.createRelay(organizationId, question) + } + + override suspend fun getRelayQuestion(relayId: Long): String { + return relayRemoteDataSource.getRelayQuestion(relayId) + } + + override suspend fun receiveRelay( + relayId: Int, + senderId: Long, + question: String + ): RelayReceive { + return relayRemoteDataSource.receiveRelay(relayId, senderId, question) + } + + override suspend fun sendRelay(relayId: Int): RelaySend { + return relayRemoteDataSource.sendRelay(relayId) + } + + +} \ No newline at end of file diff --git a/android/data/src/main/java/com/sixkids/data/repository/relay/remote/RelayHistoryPagingSource.kt b/android/data/src/main/java/com/sixkids/data/repository/relay/remote/RelayHistoryPagingSource.kt new file mode 100644 index 00000000..e9e67f46 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/repository/relay/remote/RelayHistoryPagingSource.kt @@ -0,0 +1,51 @@ +package com.sixkids.data.repository.relay.remote + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.sixkids.data.api.RelayService +import com.sixkids.data.model.response.toModel +import com.sixkids.model.Relay +import javax.inject.Inject + +class RelayHistoryPagingSource @Inject constructor( + private val relayService: RelayService, + private val organizationId: Int, + private val memberId: Int? = null, +) : PagingSource() { + override fun getRefreshKey(state: PagingState): Int? { + return state.anchorPosition?.let { anchorPosition -> + state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1) + ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1) + } + } + + override suspend fun load(params: LoadParams): LoadResult { + val page = params.key ?: 0 + return try { + + val response = relayService.getRelayHistory( + organizationId, + memberId, + page, + DEFAULT_SIZE + ) + val challengeHistory = response.getOrThrow().data.let {relayResponse -> + relayResponse.relays.map{it.toModel(relayResponse.totalCount)} + } + + + LoadResult.Page( + data = challengeHistory, + prevKey = if (page == 0) null else page.minus(1), + nextKey = if (challengeHistory.isEmpty()) null else page.plus(1) + ) + } catch (e: Exception) { + LoadResult.Error(e) + } + } + + companion object { + const val DEFAULT_SIZE = 10 + } + +} \ No newline at end of file diff --git a/android/data/src/main/java/com/sixkids/data/repository/relay/remote/RelayRemoteDataSource.kt b/android/data/src/main/java/com/sixkids/data/repository/relay/remote/RelayRemoteDataSource.kt new file mode 100644 index 00000000..0c02b141 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/repository/relay/remote/RelayRemoteDataSource.kt @@ -0,0 +1,20 @@ +package com.sixkids.data.repository.relay.remote + +import com.sixkids.model.RelayDetail +import com.sixkids.model.RelayReceive +import com.sixkids.model.RelaySend +import com.sixkids.model.RunningRelay + +interface RelayRemoteDataSource { + suspend fun getRunningRelay(organizationId: Long) : RunningRelay + + suspend fun getRelayDetail(relayId: Long) : RelayDetail + + suspend fun createRelay(organizationId: Int, question: String) : Long + + suspend fun getRelayQuestion(relayId: Long) : String + + suspend fun receiveRelay(relayId: Int, senderId: Long, question: String) : RelayReceive + + suspend fun sendRelay(relayId: Int) : RelaySend +} \ No newline at end of file diff --git a/android/data/src/main/java/com/sixkids/data/repository/relay/remote/RelayRemoteDataSourceImpl.kt b/android/data/src/main/java/com/sixkids/data/repository/relay/remote/RelayRemoteDataSourceImpl.kt new file mode 100644 index 00000000..84438efd --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/repository/relay/remote/RelayRemoteDataSourceImpl.kt @@ -0,0 +1,43 @@ +package com.sixkids.data.repository.relay.remote + +import com.sixkids.data.api.RelayService +import com.sixkids.data.model.request.ReceiveRelayRequest +import com.sixkids.data.model.request.RelayCreateRequest +import com.sixkids.data.model.response.toModel +import com.sixkids.model.RelayDetail +import com.sixkids.model.RelayReceive +import com.sixkids.model.RelaySend +import com.sixkids.model.RunningRelay +import javax.inject.Inject + +class RelayRemoteDataSourceImpl @Inject constructor( + private val relayService: RelayService +) : RelayRemoteDataSource{ + override suspend fun getRunningRelay(organizationId: Long) : RunningRelay = + relayService.getRunningRelay(organizationId).getOrThrow().data.toModel() + + override suspend fun getRelayDetail(relayId: Long): RelayDetail { + return relayService.getRelayDetail(relayId).getOrThrow().data.toModel() + } + + override suspend fun createRelay(organizationId: Int, question: String): Long { + return relayService.createRelay(RelayCreateRequest(organizationId, question)).getOrThrow().data + } + + override suspend fun getRelayQuestion(relayId: Long): String { + return relayService.getRelayQuestion(relayId).getOrThrow().data + } + + override suspend fun receiveRelay( + relayId: Int, + senderId: Long, + question: String + ): RelayReceive { + return relayService.receiveRelay(relayId, ReceiveRelayRequest(senderId, question)).getOrThrow().data.toModel() + } + + override suspend fun sendRelay(relayId: Int): RelaySend { + return relayService.sendRelay(relayId).getOrThrow().data.toModel() + } + +} \ No newline at end of file diff --git a/android/data/src/main/java/com/sixkids/data/repository/user/UserRepositoryImpl.kt b/android/data/src/main/java/com/sixkids/data/repository/user/UserRepositoryImpl.kt new file mode 100644 index 00000000..f5054b0a --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/repository/user/UserRepositoryImpl.kt @@ -0,0 +1,97 @@ +package com.sixkids.data.repository.user + +import com.sixkids.data.model.response.toModel +import com.sixkids.data.repository.user.local.UserLocalDataSource +import com.sixkids.data.repository.user.remote.UserRemoteDataSource +import com.sixkids.domain.repository.TokenRepository +import com.sixkids.domain.repository.UserRepository +import com.sixkids.model.JwtToken +import com.sixkids.model.MemberSimple +import com.sixkids.model.StudentHomeInfo +import com.sixkids.model.UserInfo +import java.io.File +import javax.inject.Inject + +class UserRepositoryImpl @Inject constructor( + private val userRemoteDataSource: UserRemoteDataSource, + private val userLocalDataSource: UserLocalDataSource, + private val tokenRepository: TokenRepository +) : UserRepository { + override suspend fun signIn(idToken: String): JwtToken { + try { + val response = userRemoteDataSource.signIn(idToken) + + tokenRepository.saveAccessToken(response.accessToken) + tokenRepository.saveRefreshToken(response.refreshToken) + + return response + } catch (e: Exception) { + tokenRepository.saveIdToken(idToken) + throw e + } + } + + override suspend fun signUp( + file: File?, + defaultImage: Int, + role: String + ): JwtToken { + try { + val idToken = tokenRepository.getIdToken() + val response = userRemoteDataSource.signUp(file, idToken, defaultImage, role) + + tokenRepository.saveAccessToken(response.accessToken) + tokenRepository.saveRefreshToken(response.refreshToken) + + tokenRepository.deleteIdToken() + + return response + } catch (e: Exception) { + throw e + } + } + + override suspend fun getRole(): String { + return userLocalDataSource.getRole() + } + + override suspend fun getMemberInfo(): UserInfo { + return userRemoteDataSource.getMemberInfo() + } + + override suspend fun getMemberSimpleInfo(id: Long): MemberSimple { + return userRemoteDataSource.getMemberSimple(id) + } + + override suspend fun updateMemberProfilePhoto(file: File?, defaultImage: Int): String { + return userRemoteDataSource.updateMemberProfilePhoto(file, defaultImage) + } + + override suspend fun signOut(): Boolean { + return userLocalDataSource.signOut() + } + + override suspend fun updateFCMToken(fcmToken: String) = + userRemoteDataSource.updateFCMToken(fcmToken) + + override suspend fun autoSignIn(): JwtToken { + try { + val response = userRemoteDataSource.autoSignIn() + + tokenRepository.saveAccessToken(response.accessToken) + tokenRepository.saveRefreshToken(response.refreshToken) + + return response + }catch (e: Exception){ + throw e + } + } + + override suspend fun loadUserInfo(): UserInfo { + return userLocalDataSource.getUserInfo() + } + + override suspend fun getStudentHomeInfo(organizationId: Long): StudentHomeInfo { + return userRemoteDataSource.getStudentHomeInfo(organizationId).toModel() + } +} diff --git a/android/data/src/main/java/com/sixkids/data/repository/user/local/UserLocalDataSource.kt b/android/data/src/main/java/com/sixkids/data/repository/user/local/UserLocalDataSource.kt new file mode 100644 index 00000000..053c7019 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/repository/user/local/UserLocalDataSource.kt @@ -0,0 +1,22 @@ +package com.sixkids.data.repository.user.local + +import com.sixkids.model.UserInfo + +interface UserLocalDataSource { + suspend fun getRole() : String + suspend fun saveRole(role: String) + + suspend fun getUserId() : Int + suspend fun saveUserId(userId: Int) + + suspend fun getUserName() : String + suspend fun saveUserName(userName: String) + + suspend fun getUserProfileImage() : String + suspend fun saveUserProfileImage(image: String) + + suspend fun getUserInfo() : UserInfo + suspend fun saveUserInfo(id: Int, name: String, email: String, photo: String, role: String) + + suspend fun signOut() : Boolean +} \ No newline at end of file diff --git a/android/data/src/main/java/com/sixkids/data/repository/user/local/UserLocalDataSourceImpl.kt b/android/data/src/main/java/com/sixkids/data/repository/user/local/UserLocalDataSourceImpl.kt new file mode 100644 index 00000000..4640bed7 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/repository/user/local/UserLocalDataSourceImpl.kt @@ -0,0 +1,110 @@ +package com.sixkids.data.repository.user.local + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import com.sixkids.model.UserInfo +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class UserLocalDataSourceImpl @Inject constructor( + private val dataStore: DataStore +) : UserLocalDataSource { + override suspend fun getRole(): String { + return dataStore.data.map { preferences -> + preferences[ROLE_KEY] ?: "" + }.first() + } + + override suspend fun saveRole(role: String) { + dataStore.edit { preferences -> + preferences[ROLE_KEY] = role + } + } + + override suspend fun getUserId(): Int { + return dataStore.data.map { preferences -> + preferences[ID_KEY] ?: 0 + }.first() + } + + override suspend fun saveUserId(userId: Int) { + dataStore.edit { preferences -> + preferences[ID_KEY] = userId + } + } + + override suspend fun getUserName(): String { + return dataStore.data.map { preferences -> + preferences[NAME_KEY] ?: "" + }.first() + } + + override suspend fun saveUserName(userName: String) { + dataStore.edit { preferences -> + preferences[NAME_KEY] = userName + } + } + + override suspend fun getUserProfileImage(): String { + return dataStore.data.map { preferences -> + preferences[IMAGE_KEY] ?: "" + }.first() + } + + override suspend fun saveUserProfileImage(image: String) { + dataStore.edit { preferences -> + preferences[IMAGE_KEY] = image + } + } + + override suspend fun getUserInfo(): UserInfo { + return dataStore.data.map { preferences -> + UserInfo( + id = preferences[ID_KEY] ?: 0, + name = preferences[NAME_KEY] ?: "", + email = preferences[EMAIL_KEY] ?: "", + photo = preferences[IMAGE_KEY] ?: "", + role = preferences[ROLE_KEY] ?: "" + ) + }.first() + } + + override suspend fun saveUserInfo( + id: Int, + name: String, + email: String, + photo: String, + role: String + ) { + dataStore.edit { preferences -> + preferences[ID_KEY] = id + preferences[NAME_KEY] = name + preferences[EMAIL_KEY] = email + preferences[IMAGE_KEY] = photo + preferences[ROLE_KEY] = role + } + } + + override suspend fun signOut(): Boolean { + return try { + dataStore.edit { preferences -> + preferences.clear() + } + true + }catch (e: Exception){ + false + } + } + + companion object { + val ROLE_KEY = stringPreferencesKey("role") + val ID_KEY = intPreferencesKey("id") + val NAME_KEY = stringPreferencesKey("name") + val IMAGE_KEY = stringPreferencesKey("image") + val EMAIL_KEY = stringPreferencesKey("email") + } +} \ No newline at end of file diff --git a/android/data/src/main/java/com/sixkids/data/repository/user/remote/UserRemoteDataSource.kt b/android/data/src/main/java/com/sixkids/data/repository/user/remote/UserRemoteDataSource.kt new file mode 100644 index 00000000..a4dff1ad --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/repository/user/remote/UserRemoteDataSource.kt @@ -0,0 +1,25 @@ +package com.sixkids.data.repository.user.remote + +import com.sixkids.data.model.response.StudentHomeResponse +import com.sixkids.model.JwtToken +import com.sixkids.model.MemberSimple +import com.sixkids.model.UserInfo +import java.io.File + +interface UserRemoteDataSource { + suspend fun signIn(idToken: String): JwtToken + + suspend fun signUp(file: File?, idToken: String, defaultImage: Int, role: String): JwtToken + + suspend fun getMemberInfo(): UserInfo + + suspend fun getMemberSimple(id: Long): MemberSimple + + suspend fun updateMemberProfilePhoto(file: File?, defaultImage: Int): String + + suspend fun autoSignIn(): JwtToken + + suspend fun updateFCMToken(fcmToken: String) + + suspend fun getStudentHomeInfo(organizationId: Long) : StudentHomeResponse +} diff --git a/android/data/src/main/java/com/sixkids/data/repository/user/remote/UserRemoteDataSourceImpl.kt b/android/data/src/main/java/com/sixkids/data/repository/user/remote/UserRemoteDataSourceImpl.kt new file mode 100644 index 00000000..af989e6b --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/repository/user/remote/UserRemoteDataSourceImpl.kt @@ -0,0 +1,117 @@ +package com.sixkids.data.repository.user.remote + +import com.sixkids.data.api.MemberOrgService +import com.sixkids.data.api.MemberService +import com.sixkids.data.api.SignInService +import com.sixkids.data.model.request.FcmRequest +import com.sixkids.data.model.request.SignInRequest +import com.sixkids.data.model.response.toModel +import com.sixkids.data.repository.user.local.UserLocalDataSource +import com.sixkids.model.JwtToken +import com.sixkids.model.MemberSimple +import com.sixkids.model.UserInfo +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.asRequestBody +import java.io.File +import javax.inject.Inject + +class UserRemoteDataSourceImpl @Inject constructor( + private val signInService: SignInService, + private val memberService: MemberService, + private val userLocalDataSource: UserLocalDataSource, + private val memberOrgService: MemberOrgService +) : UserRemoteDataSource{ + override suspend fun signIn(idToken: String): JwtToken { + val response = signInService.signIn(SignInRequest(idToken)) + if (response.getOrNull() != null) { + userLocalDataSource.saveRole(response.getOrNull()?.data?.role ?: "error") + } + return response.getOrThrow().data.toModel() + } + + override suspend fun signUp( + file: File?, + idToken: String, + defaultImage: Int, + role: String + ): JwtToken { + val data = HashMap() + + var multipartBody : MultipartBody.Part? = null + + if (file != null) { + val image = file.asRequestBody("image/*".toMediaTypeOrNull()) + multipartBody = MultipartBody.Part.createFormData("file", file.name, image) + } + + val _idToken = RequestBody.create("text/plain".toMediaTypeOrNull(), idToken) + val _defaultImage = RequestBody.create("text/plain".toMediaTypeOrNull(), defaultImage.toString()) + val _role = RequestBody.create("text/plain".toMediaTypeOrNull(), role) + + data["idToken"] = _idToken + data["defaultImage"] = _defaultImage + data["role"] = _role + + val response = signInService.signUp(multipartBody, data) + if (response.getOrNull() != null) { + userLocalDataSource.saveRole(response.getOrNull()?.data?.role ?: "error") + } + return response.getOrThrow().data.toModel() + } + + override suspend fun getMemberInfo(): UserInfo { + val response = memberService.getMemberInfo() + if (response.getOrNull() != null) { + userLocalDataSource.saveUserId(response.getOrNull()?.data?.id ?: 0) + userLocalDataSource.saveUserName(response.getOrNull()?.data?.name ?: "") + userLocalDataSource.saveUserProfileImage(response.getOrNull()?.data?.photo ?: "") + userLocalDataSource.saveUserInfo( + response.getOrNull()?.data?.id ?: 0, + response.getOrNull()?.data?.name ?: "", + response.getOrNull()?.data?.email ?: "", + response.getOrNull()?.data?.photo ?: "", + response.getOrNull()?.data?.role ?: "" + ) + } + return response.getOrThrow().data.toModel() + } + + override suspend fun getMemberSimple(id: Long): MemberSimple = + memberService.getMemberInfoById(id).getOrThrow().data.toModel() + + override suspend fun updateMemberProfilePhoto(file: File?, defaultImage: Int): String { + val data = HashMap() + + var multipartBody : MultipartBody.Part? = null + + if (file != null) { + val image = file.asRequestBody("image/*".toMediaTypeOrNull()) + multipartBody = MultipartBody.Part.createFormData("file", file.name, image) + } + + val _defaultImage = RequestBody.create("text/plain".toMediaTypeOrNull(), defaultImage.toString()) + data["defaultImage"] = _defaultImage + + val response = memberService.updateMemberProfilePhoto(multipartBody, data) + + if (response.getOrNull() != null) { + userLocalDataSource.saveUserProfileImage(response.getOrNull()?.data?.photoImageUrl ?: "") + } + + return response.getOrThrow().data.photoImageUrl + } + + override suspend fun updateFCMToken(fcmToken: String) = memberService.updateFCMToken(FcmRequest(fcmToken)).getOrThrow().data + + override suspend fun autoSignIn(): JwtToken { + val response = memberService.autoSignIn() + if (response.getOrNull() != null) { + userLocalDataSource.saveRole(response.getOrNull()?.data?.role ?: "error") + } + return response.getOrThrow().data.toModel() + } + + override suspend fun getStudentHomeInfo(organizationId: Long) = memberOrgService.getStudentHomeInfo(organizationId).getOrThrow().data +} diff --git a/android/data/src/main/java/com/sixkids/data/util/LocalDateAdapter.kt b/android/data/src/main/java/com/sixkids/data/util/LocalDateAdapter.kt new file mode 100644 index 00000000..3e19c346 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/util/LocalDateAdapter.kt @@ -0,0 +1,22 @@ +package com.sixkids.data.util + +import com.squareup.moshi.FromJson +import com.squareup.moshi.ToJson +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +class LocalDateAdapter { + @ToJson + fun toJson(value: LocalDate): String { + return FORMATTER.format(value) + } + + @FromJson + fun fromJson(value: String): LocalDate { + return LocalDate.parse(value, FORMATTER) + } + + companion object { + private val FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE + } +} \ No newline at end of file diff --git a/android/data/src/main/java/com/sixkids/data/util/LocalDateTimeAdapter.kt b/android/data/src/main/java/com/sixkids/data/util/LocalDateTimeAdapter.kt new file mode 100644 index 00000000..a977bc81 --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/util/LocalDateTimeAdapter.kt @@ -0,0 +1,22 @@ +package com.sixkids.data.util + +import com.squareup.moshi.FromJson +import com.squareup.moshi.ToJson +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +class LocalDateTimeAdapter { + @ToJson + fun toJson(value: LocalDateTime): String { + return FORMATTER.format(value) + } + + @FromJson + fun fromJson(value: String): LocalDateTime { + return LocalDateTime.parse(value, FORMATTER) + } + + companion object { + private val FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME + } +} diff --git a/android/data/src/main/java/com/sixkids/data/util/UnitJsonAdapter.kt b/android/data/src/main/java/com/sixkids/data/util/UnitJsonAdapter.kt new file mode 100644 index 00000000..84c0d8de --- /dev/null +++ b/android/data/src/main/java/com/sixkids/data/util/UnitJsonAdapter.kt @@ -0,0 +1,21 @@ +package com.sixkids.data.util + +import com.squareup.moshi.FromJson +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter +import com.squareup.moshi.ToJson + +class UnitJsonAdapter : JsonAdapter() { + @ToJson + override fun toJson(writer: JsonWriter, value: Unit?) { + writer.nullValue() + } + + @FromJson + override fun fromJson(reader: JsonReader): Unit? { + reader.skipValue() + return Unit + } + +} diff --git a/android/domain/build.gradle.kts b/android/domain/build.gradle.kts index 10600601..af30b201 100644 --- a/android/domain/build.gradle.kts +++ b/android/domain/build.gradle.kts @@ -4,4 +4,6 @@ plugins { dependencies { implementation(projects.core.model) implementation(libs.javax.inject) + implementation(libs.paging.common) + implementation(libs.kotlinx.coroutines.core) } diff --git a/android/domain/src/main/java/com/sixkids/domain/repository/ChallengeRepository.kt b/android/domain/src/main/java/com/sixkids/domain/repository/ChallengeRepository.kt new file mode 100644 index 00000000..9df6f72b --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/repository/ChallengeRepository.kt @@ -0,0 +1,41 @@ +package com.sixkids.domain.repository + +import androidx.paging.PagingData +import com.sixkids.model.AcceptStatus +import com.sixkids.model.Challenge +import com.sixkids.model.ChallengeDetail +import com.sixkids.model.GroupSimple +import com.sixkids.model.RunningChallenge +import com.sixkids.model.RunningChallengeByStudent +import kotlinx.coroutines.flow.Flow +import java.time.LocalDateTime + +interface ChallengeRepository { + suspend fun getChallengeHistory( + organizationId: Int, + memberId: Int? + ): Flow> + + suspend fun getRunningChallenge(organizationId: Int): RunningChallenge + + suspend fun getRunningChallengesByStudent(organizationId: Int): RunningChallengeByStudent + + suspend fun createChallenge( + organizationId: Int, + title: String, + content: String, + startTime: LocalDateTime, + endTime: LocalDateTime, + reward: Int, + minCount: Int, + groups: List + ): Long + + suspend fun getChallengeSimple( + challengeId: Int + ): Challenge + + suspend fun getChallengeDetail(challengeId: Long, groupId: Long?): ChallengeDetail + + suspend fun gradingChallenge(reportId: Long, acceptStatus: AcceptStatus) +} diff --git a/android/domain/src/main/java/com/sixkids/domain/repository/ChattingFilterRepository.kt b/android/domain/src/main/java/com/sixkids/domain/repository/ChattingFilterRepository.kt new file mode 100644 index 00000000..5a79ebd7 --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/repository/ChattingFilterRepository.kt @@ -0,0 +1,27 @@ +package com.sixkids.domain.repository + +import androidx.paging.PagingData +import com.sixkids.model.ChatFilterWord +import kotlinx.coroutines.flow.Flow + +interface ChattingFilterRepository { + + suspend fun getChattingFilters( + organizationId: Int + ): Flow> + + suspend fun deleteChatFilter( + id: Long + ): Boolean + + suspend fun createChatFilter( + organizationId: Long, + badWord: String, + ): Long + + suspend fun updateChatFilter( + organizationId: Long, + id: Long, + badWord: String, + ): Boolean +} \ No newline at end of file diff --git a/android/domain/src/main/java/com/sixkids/domain/repository/ChattingRepository.kt b/android/domain/src/main/java/com/sixkids/domain/repository/ChattingRepository.kt new file mode 100644 index 00000000..4da8db8d --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/repository/ChattingRepository.kt @@ -0,0 +1,9 @@ +package com.sixkids.domain.repository + +import androidx.paging.PagingData +import com.sixkids.model.Chat +import kotlinx.coroutines.flow.Flow + +interface ChattingRepository { + suspend fun getChattingList(roomId: Long): Flow> +} \ No newline at end of file diff --git a/android/domain/src/main/java/com/sixkids/domain/repository/CommentRepository.kt b/android/domain/src/main/java/com/sixkids/domain/repository/CommentRepository.kt new file mode 100644 index 00000000..da95ef4d --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/repository/CommentRepository.kt @@ -0,0 +1,11 @@ +package com.sixkids.domain.repository + +interface CommentRepository { + suspend fun createComment(postId: Long, content: String, parentId: Long): Long + + suspend fun deleteComment(id: Long): Boolean + + suspend fun updateComment(id: Long, content: String): Long + + suspend fun reportComment(id: Long): Boolean +} \ No newline at end of file diff --git a/android/domain/src/main/java/com/sixkids/domain/repository/GroupRepository.kt b/android/domain/src/main/java/com/sixkids/domain/repository/GroupRepository.kt new file mode 100644 index 00000000..6033e4f4 --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/repository/GroupRepository.kt @@ -0,0 +1,23 @@ +package com.sixkids.domain.repository + +import com.sixkids.model.ChallengeGroup +import com.sixkids.model.MatchingRoom + +interface GroupRepository { + suspend fun createMatchingRoom(challengeId: Long): MatchingRoom + + suspend fun inviteFriend(key: String, memberId: Long) + + suspend fun deportFriend(key: String, memberId: Long) + + suspend fun joinGroup(key: String, joinStatus: Boolean) + + suspend fun createGroup(key: String): Long + + suspend fun getMatchingGroup( + organizationId: Long, + minCount: Int, + matchingType: String, + members: List + ): List +} diff --git a/android/domain/src/main/java/com/sixkids/domain/repository/OrganizationRepository.kt b/android/domain/src/main/java/com/sixkids/domain/repository/OrganizationRepository.kt new file mode 100644 index 00000000..c0d2ffe7 --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/repository/OrganizationRepository.kt @@ -0,0 +1,42 @@ +package com.sixkids.domain.repository + +import com.sixkids.model.MemberDetail +import com.sixkids.model.ClassSummary +import com.sixkids.model.MemberRankItem +import com.sixkids.model.MemberSimple +import com.sixkids.model.MemberSimpleWithScore +import com.sixkids.model.Organization +import com.sixkids.model.StudentRelation + +interface OrganizationRepository { + suspend fun getClassList(): List + + suspend fun saveSelectedOrganizationId(organizationId: Int) + suspend fun getSelectedOrganizationId(): Int + + suspend fun newOrganization(name: String): Long + + suspend fun joinOrganization(orgId: Int, code: String): Long + + suspend fun getOrganizationSummary(organizationId: Int): ClassSummary + + suspend fun updateOrganization(organizationId: Int, name: String): String + + suspend fun getOrganizationInviteCode(organizationId: Int): String + + suspend fun saveSelectedOrganizationName(organizationName: String) + + suspend fun loadSelectedOrganizationName(): String + + suspend fun getOrganizationMembers(orgId: Int): List + + suspend fun getStudentDetail(orgId: Long, studentId: Long): MemberDetail + + suspend fun getStudentRelation(orgId: Long, studentId: Long, limit: Int?): List + + suspend fun getStudentRelationDetail(orgId: Long, sourceStudentId: Long, targetStudentId: Long): StudentRelation + + suspend fun getOrganizationRank(orgId: Int): List + + suspend fun tagGreeting(orgId: Long, memberId: Long): Int +} \ No newline at end of file diff --git a/android/domain/src/main/java/com/sixkids/domain/repository/PostRepository.kt b/android/domain/src/main/java/com/sixkids/domain/repository/PostRepository.kt new file mode 100644 index 00000000..e54b5269 --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/repository/PostRepository.kt @@ -0,0 +1,47 @@ +package com.sixkids.domain.repository + +import androidx.paging.PagingData +import com.sixkids.model.Post +import com.sixkids.model.PostDetail +import kotlinx.coroutines.flow.Flow +import java.io.File + +interface PostRepository { + + suspend fun getPosts( + organizationId: Int, + memberId: Int? = null, + postCategory: String, + ): Flow> + + suspend fun createPost( + organizationId: Long, + title: String, + content: String, + secretStatus: Boolean, + postCategory: String, + file: File?, + ): Long + + suspend fun getPostDetail( + postId: Long, + ): PostDetail + + suspend fun deletePost( + postId: Long, + ): Boolean + + suspend fun updatePost( + postId: Long, + title: String, + content: String, + secretStatus: Boolean, + postCategory: String, + file: File?, + ): Long + + suspend fun reportPost( + postId: Long, + ): Boolean + +} \ No newline at end of file diff --git a/android/domain/src/main/java/com/sixkids/domain/repository/RelayRepository.kt b/android/domain/src/main/java/com/sixkids/domain/repository/RelayRepository.kt new file mode 100644 index 00000000..90c7a4ae --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/repository/RelayRepository.kt @@ -0,0 +1,28 @@ +package com.sixkids.domain.repository + +import androidx.paging.PagingData +import com.sixkids.model.Relay +import com.sixkids.model.RelayDetail +import com.sixkids.model.RelayReceive +import com.sixkids.model.RelaySend +import com.sixkids.model.RunningRelay +import kotlinx.coroutines.flow.Flow + +interface RelayRepository { + suspend fun getRelayHistory( + organizationId: Int, + memberId: Int? + ): Flow> + + suspend fun getRunningRelay(organizationId: Long): RunningRelay + + suspend fun getRelayDetail(relayId: Long): RelayDetail + + suspend fun createRelay(organizationId: Int, question: String): Long + + suspend fun getRelayQuestion(relayId: Long): String + + suspend fun receiveRelay(relayId: Int, senderId: Long, question: String) : RelayReceive + + suspend fun sendRelay(relayId: Int): RelaySend +} \ No newline at end of file diff --git a/android/domain/src/main/java/com/sixkids/domain/repository/TokenRepository.kt b/android/domain/src/main/java/com/sixkids/domain/repository/TokenRepository.kt index 8288c3b3..cafc174b 100644 --- a/android/domain/src/main/java/com/sixkids/domain/repository/TokenRepository.kt +++ b/android/domain/src/main/java/com/sixkids/domain/repository/TokenRepository.kt @@ -5,5 +5,8 @@ interface TokenRepository { suspend fun saveAccessToken(token: String) suspend fun getRefreshToken(): String suspend fun saveRefreshToken(token: String) + suspend fun saveIdToken(token: String) + suspend fun getIdToken(): String + suspend fun deleteIdToken() suspend fun clearTokens() } \ No newline at end of file diff --git a/android/domain/src/main/java/com/sixkids/domain/repository/UserRepository.kt b/android/domain/src/main/java/com/sixkids/domain/repository/UserRepository.kt new file mode 100644 index 00000000..23418399 --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/repository/UserRepository.kt @@ -0,0 +1,31 @@ +package com.sixkids.domain.repository + +import com.sixkids.model.JwtToken +import com.sixkids.model.MemberSimple +import com.sixkids.model.StudentHomeInfo +import com.sixkids.model.UserInfo +import java.io.File + +interface UserRepository { + suspend fun signIn(idToken: String): JwtToken + + suspend fun signUp(file: File?, defaultImage: Int, role: String): JwtToken + + suspend fun getRole(): String + + suspend fun getMemberInfo(): UserInfo + + suspend fun getMemberSimpleInfo(id: Long): MemberSimple + + suspend fun updateMemberProfilePhoto(file: File?, defaultImage: Int): String + + suspend fun signOut() : Boolean + + suspend fun updateFCMToken(fcmToken: String) + + suspend fun autoSignIn(): JwtToken + + suspend fun loadUserInfo(): UserInfo + + suspend fun getStudentHomeInfo(organizationId: Long): StudentHomeInfo +} diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/challenge/CreateChallengeUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/challenge/CreateChallengeUseCase.kt new file mode 100644 index 00000000..242f9455 --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/challenge/CreateChallengeUseCase.kt @@ -0,0 +1,32 @@ +package com.sixkids.domain.usecase.challenge + +import com.sixkids.domain.repository.ChallengeRepository +import com.sixkids.model.GroupSimple +import java.time.LocalDateTime +import javax.inject.Inject + +class CreateChallengeUseCase @Inject constructor( + private val challengeRepository: ChallengeRepository +) { + suspend operator fun invoke( + organizationId: Int, + title: String, + content: String, + startTime: LocalDateTime, + endTime: LocalDateTime, + reward: Int, + minCount: Int, + groups: List = emptyList(), + ) = runCatching { + challengeRepository.createChallenge( + organizationId = organizationId, + title = title, + content = content, + startTime = startTime, + endTime = endTime, + reward = reward, + minCount = minCount, + groups = groups + ) + } +} diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/challenge/GetChallengeDetailUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/challenge/GetChallengeDetailUseCase.kt new file mode 100644 index 00000000..0d08b0e2 --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/challenge/GetChallengeDetailUseCase.kt @@ -0,0 +1,12 @@ +package com.sixkids.domain.usecase.challenge + +import com.sixkids.domain.repository.ChallengeRepository +import javax.inject.Inject + +class GetChallengeDetailUseCase @Inject constructor( + private val challengeRepository: ChallengeRepository +) { + suspend operator fun invoke(challengeId: Long, groupId: Long?) = runCatching { + challengeRepository.getChallengeDetail(challengeId, groupId) + } +} diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/challenge/GetChallengeHistoryUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/challenge/GetChallengeHistoryUseCase.kt new file mode 100644 index 00000000..9caeb373 --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/challenge/GetChallengeHistoryUseCase.kt @@ -0,0 +1,13 @@ +package com.sixkids.domain.usecase.challenge + +import com.sixkids.domain.repository.ChallengeRepository +import javax.inject.Inject + +class GetChallengeHistoryUseCase @Inject constructor( + private val challengeRepository: ChallengeRepository +) { + suspend operator fun invoke( + organizationId: Int, + memberId: Int? = null + ) = challengeRepository.getChallengeHistory(organizationId, memberId) +} diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/challenge/GetChallengeSimpleUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/challenge/GetChallengeSimpleUseCase.kt new file mode 100644 index 00000000..581333ad --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/challenge/GetChallengeSimpleUseCase.kt @@ -0,0 +1,12 @@ +package com.sixkids.domain.usecase.challenge + +import com.sixkids.domain.repository.ChallengeRepository +import javax.inject.Inject + +class GetChallengeSimpleUseCase @Inject constructor( + private val challengeRepository: ChallengeRepository +) { + suspend operator fun invoke(challengeId: Int) = runCatching { + challengeRepository.getChallengeSimple(challengeId) + } +} diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/challenge/GetRunningChallengeByStudentUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/challenge/GetRunningChallengeByStudentUseCase.kt new file mode 100644 index 00000000..f69c3dce --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/challenge/GetRunningChallengeByStudentUseCase.kt @@ -0,0 +1,13 @@ +package com.sixkids.domain.usecase.challenge + +import com.sixkids.domain.repository.ChallengeRepository +import javax.inject.Inject + +class GetRunningChallengeByStudentUseCase @Inject constructor( + private val challengeRepository: ChallengeRepository +){ + suspend operator fun invoke(organizationId: Int) = + runCatching { + challengeRepository.getRunningChallengesByStudent(organizationId) + } +} diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/challenge/GetRunningChallengeUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/challenge/GetRunningChallengeUseCase.kt new file mode 100644 index 00000000..194f1bd2 --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/challenge/GetRunningChallengeUseCase.kt @@ -0,0 +1,12 @@ +package com.sixkids.domain.usecase.challenge + +import com.sixkids.domain.repository.ChallengeRepository +import javax.inject.Inject + +class GetRunningChallengeUseCase @Inject constructor( + private val challengeRepository: ChallengeRepository +) { + suspend operator fun invoke(organizationId: Int) = runCatching { + challengeRepository.getRunningChallenge(organizationId) + } +} diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/challenge/GradingReportUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/challenge/GradingReportUseCase.kt new file mode 100644 index 00000000..a78d165e --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/challenge/GradingReportUseCase.kt @@ -0,0 +1,16 @@ +package com.sixkids.domain.usecase.challenge + +import com.sixkids.domain.repository.ChallengeRepository +import com.sixkids.model.AcceptStatus +import javax.inject.Inject + +class GradingReportUseCase @Inject constructor( + private val challengeRepository: ChallengeRepository +) { + suspend operator fun invoke(reportId: Long, acceptStatus: AcceptStatus) = + runCatching { + challengeRepository.gradingChallenge( + reportId, acceptStatus + ) + } +} diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/chatting/CreateNewChattingFilterWordUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/chatting/CreateNewChattingFilterWordUseCase.kt new file mode 100644 index 00000000..0b3cacea --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/chatting/CreateNewChattingFilterWordUseCase.kt @@ -0,0 +1,16 @@ +package com.sixkids.domain.usecase.chatting + +import com.sixkids.domain.repository.ChattingFilterRepository +import javax.inject.Inject + +class CreateNewChattingFilterWordUseCase @Inject constructor( + private val chattingFilterRepository: ChattingFilterRepository +) +{ + suspend operator fun invoke( + organizationId: Long, + badWord: String + ) = runCatching { + chattingFilterRepository.createChatFilter(organizationId, badWord) + } +} \ No newline at end of file diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/chatting/DeleteChattingFilterWordUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/chatting/DeleteChattingFilterWordUseCase.kt new file mode 100644 index 00000000..59ffbf95 --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/chatting/DeleteChattingFilterWordUseCase.kt @@ -0,0 +1,14 @@ +package com.sixkids.domain.usecase.chatting + +import com.sixkids.domain.repository.ChattingFilterRepository +import javax.inject.Inject + +class DeleteChattingFilterWordUseCase @Inject constructor( + private val chattingFilterRepository: ChattingFilterRepository +){ + suspend operator fun invoke( + chattingFilterId: Long + ) = chattingFilterRepository.deleteChatFilter( + chattingFilterId + ) +} \ No newline at end of file diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/chatting/GetChattingFilterWordsUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/chatting/GetChattingFilterWordsUseCase.kt new file mode 100644 index 00000000..7354278c --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/chatting/GetChattingFilterWordsUseCase.kt @@ -0,0 +1,14 @@ +package com.sixkids.domain.usecase.chatting + +import com.sixkids.domain.repository.ChattingFilterRepository +import javax.inject.Inject + +class GetChattingFilterWordsUseCase @Inject constructor( + private val chattingFilterRepository: ChattingFilterRepository +) { + suspend operator fun invoke( + organizationId: Int + ) = chattingFilterRepository.getChattingFilters( + organizationId + ) +} \ No newline at end of file diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/chatting/GetChattingHistoryUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/chatting/GetChattingHistoryUseCase.kt new file mode 100644 index 00000000..4057439a --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/chatting/GetChattingHistoryUseCase.kt @@ -0,0 +1,11 @@ +package com.sixkids.domain.usecase.chatting + +import com.sixkids.domain.repository.ChattingRepository +import javax.inject.Inject + +class GetChattingHistoryUseCase @Inject constructor( + private val chattingRepository: ChattingRepository +){ + suspend operator fun invoke(roomId: Long) = chattingRepository.getChattingList(roomId) + +} \ No newline at end of file diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/comment/DeleteCommentUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/comment/DeleteCommentUseCase.kt new file mode 100644 index 00000000..d3c8272b --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/comment/DeleteCommentUseCase.kt @@ -0,0 +1,12 @@ +package com.sixkids.domain.usecase.comment + +import com.sixkids.domain.repository.CommentRepository +import javax.inject.Inject + +class DeleteCommentUseCase @Inject constructor( + private val commentRepository: CommentRepository +){ + suspend operator fun invoke(commentId: Long) = runCatching { + commentRepository.deleteComment(commentId) + } +} \ No newline at end of file diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/comment/NewCommentUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/comment/NewCommentUseCase.kt new file mode 100644 index 00000000..9806c477 --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/comment/NewCommentUseCase.kt @@ -0,0 +1,14 @@ +package com.sixkids.domain.usecase.comment + +import com.sixkids.domain.repository.CommentRepository +import javax.inject.Inject + +class NewCommentUseCase @Inject constructor( + private val commentRepository: CommentRepository +){ + suspend operator fun invoke( + postId: Long, content: String + ) = runCatching { + commentRepository.createComment(postId, content, 0L) + } +} \ No newline at end of file diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/comment/NewRecommentUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/comment/NewRecommentUseCase.kt new file mode 100644 index 00000000..51259c73 --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/comment/NewRecommentUseCase.kt @@ -0,0 +1,14 @@ +package com.sixkids.domain.usecase.comment + +import com.sixkids.domain.repository.CommentRepository +import javax.inject.Inject + +class NewRecommentUseCase @Inject constructor( + private val commentRepository: CommentRepository +){ + suspend operator fun invoke( + postId: Long, content: String, parentId: Long + ) = runCatching { + commentRepository.createComment(postId, content, parentId) + } +} \ No newline at end of file diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/comment/ReportCommentUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/comment/ReportCommentUseCase.kt new file mode 100644 index 00000000..46ccda3c --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/comment/ReportCommentUseCase.kt @@ -0,0 +1,12 @@ +package com.sixkids.domain.usecase.comment + +import com.sixkids.domain.repository.CommentRepository +import javax.inject.Inject + +class ReportCommentUseCase @Inject constructor( + private val commentRepository: CommentRepository +){ + suspend operator fun invoke(commentId: Long) = runCatching { + commentRepository.reportComment(commentId) + } +} \ No newline at end of file diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/comment/UpdateCommentUsecase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/comment/UpdateCommentUsecase.kt new file mode 100644 index 00000000..b3c8a29d --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/comment/UpdateCommentUsecase.kt @@ -0,0 +1,14 @@ +package com.sixkids.domain.usecase.comment + +import com.sixkids.domain.repository.CommentRepository +import javax.inject.Inject + +class UpdateCommentUsecase @Inject constructor( + private val commentRepository: CommentRepository +){ + suspend operator fun invoke( + commentId: Long, content: String + ) = runCatching { + commentRepository.updateComment(commentId, content) + } +} \ No newline at end of file diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/group/CreateGroupMatchingRoomUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/group/CreateGroupMatchingRoomUseCase.kt new file mode 100644 index 00000000..8bc4e2b9 --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/group/CreateGroupMatchingRoomUseCase.kt @@ -0,0 +1,12 @@ +package com.sixkids.domain.usecase.group + +import com.sixkids.domain.repository.GroupRepository +import javax.inject.Inject + +class CreateGroupMatchingRoomUseCase @Inject constructor( + private val groupRepository: GroupRepository +) { + suspend operator fun invoke(challengeId: Long) = runCatching { + groupRepository.createMatchingRoom(challengeId) + } +} diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/group/CreateGroupUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/group/CreateGroupUseCase.kt new file mode 100644 index 00000000..9b978867 --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/group/CreateGroupUseCase.kt @@ -0,0 +1,12 @@ +package com.sixkids.domain.usecase.group + +import com.sixkids.domain.repository.GroupRepository +import javax.inject.Inject + +class CreateGroupUseCase @Inject constructor( + private val groupRepository: GroupRepository +) { + suspend operator fun invoke(key: String) = runCatching { + groupRepository.createGroup(key) + } +} diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/group/DeportFriendUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/group/DeportFriendUseCase.kt new file mode 100644 index 00000000..32ab6b78 --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/group/DeportFriendUseCase.kt @@ -0,0 +1,15 @@ +package com.sixkids.domain.usecase.group + +import com.sixkids.domain.repository.GroupRepository +import javax.inject.Inject + +class DeportFriendUseCase @Inject constructor( + private val groupRepository: GroupRepository +) { + suspend operator fun invoke(key: String, memberId: Long) = runCatching { + groupRepository.deportFriend( + key, memberId + ) + } + +} diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/group/GetMatchingGroupUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/group/GetMatchingGroupUseCase.kt new file mode 100644 index 00000000..c31a14df --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/group/GetMatchingGroupUseCase.kt @@ -0,0 +1,17 @@ +package com.sixkids.domain.usecase.group + +import com.sixkids.domain.repository.GroupRepository +import javax.inject.Inject + +class GetMatchingGroupUseCase @Inject constructor( + private val groupRepository: GroupRepository +) { + suspend operator fun invoke( + organizationId: Long, + minCount: Int, + matchingType: String, + members: List + ) = runCatching { + groupRepository.getMatchingGroup(organizationId, minCount, matchingType, members) + } +} diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/group/InviteFriendUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/group/InviteFriendUseCase.kt new file mode 100644 index 00000000..97693c7b --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/group/InviteFriendUseCase.kt @@ -0,0 +1,12 @@ +package com.sixkids.domain.usecase.group + +import com.sixkids.domain.repository.GroupRepository +import javax.inject.Inject + +class InviteFriendUseCase @Inject constructor( + private val groupRepository: GroupRepository +) { + suspend operator fun invoke(key: String, memberId: Long) = runCatching { + groupRepository.inviteFriend(key, memberId) + } +} diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/group/JoinGroupUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/group/JoinGroupUseCase.kt new file mode 100644 index 00000000..66d63d5e --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/group/JoinGroupUseCase.kt @@ -0,0 +1,12 @@ +package com.sixkids.domain.usecase.group + +import com.sixkids.domain.repository.GroupRepository +import javax.inject.Inject + +class JoinGroupUseCase @Inject constructor( + private val groupRepository: GroupRepository +) { + suspend operator fun invoke(key: String, joinStatus: Boolean) = runCatching { + groupRepository.joinGroup(key, joinStatus) + } +} diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/organization/GetClassRankUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/organization/GetClassRankUseCase.kt new file mode 100644 index 00000000..b7e5dbc8 --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/organization/GetClassRankUseCase.kt @@ -0,0 +1,12 @@ +package com.sixkids.domain.usecase.organization + +import com.sixkids.domain.repository.OrganizationRepository +import javax.inject.Inject + +class GetClassRankUseCase @Inject constructor( + private val organizationRepository: OrganizationRepository +) { + suspend operator fun invoke(organizationId: Int) = runCatching { + organizationRepository.getOrganizationRank(organizationId) + } +} \ No newline at end of file diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/organization/GetMemberRelationUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/organization/GetMemberRelationUseCase.kt new file mode 100644 index 00000000..aceb89e6 --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/organization/GetMemberRelationUseCase.kt @@ -0,0 +1,12 @@ +package com.sixkids.domain.usecase.organization + +import com.sixkids.domain.repository.OrganizationRepository +import javax.inject.Inject + +class GetMemberRelationUseCase @Inject constructor( + private val organizationRepository: OrganizationRepository +){ + suspend operator fun invoke(orgId: Long, studentId: Long, limit: Int?) = runCatching { + organizationRepository.getStudentRelation(orgId, studentId, limit) + } +} \ No newline at end of file diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/organization/GetOrganizaionInviteCodeUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/organization/GetOrganizaionInviteCodeUseCase.kt new file mode 100644 index 00000000..cb2bf6bf --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/organization/GetOrganizaionInviteCodeUseCase.kt @@ -0,0 +1,12 @@ +package com.sixkids.domain.usecase.organization + +import com.sixkids.domain.repository.OrganizationRepository +import javax.inject.Inject + +class GetOrganizaionInviteCodeUseCase @Inject constructor( + private val organizationRepository: OrganizationRepository +) { + suspend operator fun invoke(organizationId: Int) = runCatching{ + organizationRepository.getOrganizationInviteCode(organizationId) + } +} \ No newline at end of file diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/organization/GetOrganizationListUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/organization/GetOrganizationListUseCase.kt new file mode 100644 index 00000000..829467dd --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/organization/GetOrganizationListUseCase.kt @@ -0,0 +1,12 @@ +package com.sixkids.domain.usecase.organization + +import com.sixkids.domain.repository.OrganizationRepository +import javax.inject.Inject + +class GetOrganizationListUseCase @Inject constructor( + private val organizationRepository: OrganizationRepository +){ + suspend operator fun invoke() = runCatching { + organizationRepository.getClassList() + } +} \ No newline at end of file diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/organization/GetOrganizationMemberUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/organization/GetOrganizationMemberUseCase.kt new file mode 100644 index 00000000..3fbd4103 --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/organization/GetOrganizationMemberUseCase.kt @@ -0,0 +1,12 @@ +package com.sixkids.domain.usecase.organization + +import com.sixkids.domain.repository.OrganizationRepository +import javax.inject.Inject + +class GetOrganizationMemberUseCase @Inject constructor( + private val organizationRepository: OrganizationRepository +){ + suspend operator fun invoke(orgId: Int) = runCatching { + organizationRepository.getOrganizationMembers(orgId) + } +} \ No newline at end of file diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/organization/GetOrganizationSummaryUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/organization/GetOrganizationSummaryUseCase.kt new file mode 100644 index 00000000..ed307ad2 --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/organization/GetOrganizationSummaryUseCase.kt @@ -0,0 +1,12 @@ +package com.sixkids.domain.usecase.organization + +import com.sixkids.domain.repository.OrganizationRepository +import javax.inject.Inject + +class GetOrganizationSummaryUseCase @Inject constructor( + private val organizationRepository: OrganizationRepository +){ + suspend operator fun invoke(organizationId: Int) = runCatching { + organizationRepository.getOrganizationSummary(organizationId) + } +} \ No newline at end of file diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/organization/GetSelectedOrganizationIdUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/organization/GetSelectedOrganizationIdUseCase.kt new file mode 100644 index 00000000..063c4209 --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/organization/GetSelectedOrganizationIdUseCase.kt @@ -0,0 +1,12 @@ +package com.sixkids.domain.usecase.organization + +import com.sixkids.domain.repository.OrganizationRepository +import javax.inject.Inject + +class GetSelectedOrganizationIdUseCase @Inject constructor( + private val organizationRepository: OrganizationRepository +) { + suspend operator fun invoke() = runCatching { + organizationRepository.getSelectedOrganizationId() + } +} diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/organization/GetStudentDetailUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/organization/GetStudentDetailUseCase.kt new file mode 100644 index 00000000..c5171218 --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/organization/GetStudentDetailUseCase.kt @@ -0,0 +1,13 @@ +package com.sixkids.domain.usecase.organization + +import com.sixkids.domain.repository.OrganizationRepository +import javax.inject.Inject + +class GetStudentDetailUseCase @Inject constructor( + private val organizationRepository: OrganizationRepository +) { + suspend operator fun invoke(orgId: Long, studentId: Long) = runCatching { + organizationRepository.getStudentDetail(orgId, studentId) + } + +} \ No newline at end of file diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/organization/GetStudentRelationUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/organization/GetStudentRelationUseCase.kt new file mode 100644 index 00000000..0a931e31 --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/organization/GetStudentRelationUseCase.kt @@ -0,0 +1,12 @@ +package com.sixkids.domain.usecase.organization + +import com.sixkids.domain.repository.OrganizationRepository +import javax.inject.Inject + +class GetStudentRelationUseCase @Inject constructor( + private val organizationRepository: OrganizationRepository +){ + suspend operator fun invoke(orgId: Long, sourceMemberId: Long, targetMemberId: Long) = runCatching { + organizationRepository.getStudentRelationDetail(orgId, sourceMemberId, targetMemberId) + } +} \ No newline at end of file diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/organization/GreetingUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/organization/GreetingUseCase.kt new file mode 100644 index 00000000..92238c9c --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/organization/GreetingUseCase.kt @@ -0,0 +1,12 @@ +package com.sixkids.domain.usecase.organization + +import com.sixkids.domain.repository.OrganizationRepository +import javax.inject.Inject + +class GreetingUseCase @Inject constructor( + private val organizationRepository: OrganizationRepository +){ + suspend operator fun invoke(organizationId: Long, memberId: Long) = runCatching { + organizationRepository.tagGreeting(organizationId, memberId) + } +} \ No newline at end of file diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/organization/JoinOrganizationUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/organization/JoinOrganizationUseCase.kt new file mode 100644 index 00000000..1a2d1618 --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/organization/JoinOrganizationUseCase.kt @@ -0,0 +1,12 @@ +package com.sixkids.domain.usecase.organization + +import com.sixkids.domain.repository.OrganizationRepository +import javax.inject.Inject + +class JoinOrganizationUseCase @Inject constructor( + private val organizationRepository: OrganizationRepository +){ + suspend operator fun invoke(orgId: Int, code: String) = runCatching { + organizationRepository.joinOrganization(orgId, code) + } +} \ No newline at end of file diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/organization/LoadSelectedOrganizationNameUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/organization/LoadSelectedOrganizationNameUseCase.kt new file mode 100644 index 00000000..56ac01e8 --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/organization/LoadSelectedOrganizationNameUseCase.kt @@ -0,0 +1,12 @@ +package com.sixkids.domain.usecase.organization + +import com.sixkids.domain.repository.OrganizationRepository +import javax.inject.Inject + +class LoadSelectedOrganizationNameUseCase @Inject constructor( + private val organizationRepository: OrganizationRepository +){ + suspend operator fun invoke() = runCatching { + organizationRepository.loadSelectedOrganizationName() + } +} \ No newline at end of file diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/organization/NewOrganizationUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/organization/NewOrganizationUseCase.kt new file mode 100644 index 00000000..afb88a90 --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/organization/NewOrganizationUseCase.kt @@ -0,0 +1,12 @@ +package com.sixkids.domain.usecase.organization + +import com.sixkids.domain.repository.OrganizationRepository +import javax.inject.Inject + +class NewOrganizationUseCase @Inject constructor( + private val organizationRepository: OrganizationRepository +) { + suspend operator fun invoke(name: String) = runCatching { + organizationRepository.newOrganization(name) + } +} \ No newline at end of file diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/organization/SaveSelectedOrganizationIdUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/organization/SaveSelectedOrganizationIdUseCase.kt new file mode 100644 index 00000000..abd52af8 --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/organization/SaveSelectedOrganizationIdUseCase.kt @@ -0,0 +1,12 @@ +package com.sixkids.domain.usecase.organization + +import com.sixkids.domain.repository.OrganizationRepository +import javax.inject.Inject + +class SaveSelectedOrganizationIdUseCase @Inject constructor( + private val organizationRepository: OrganizationRepository +){ + suspend operator fun invoke(organizationId: Int) { + organizationRepository.saveSelectedOrganizationId(organizationId) + } +} \ No newline at end of file diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/organization/SaveSelectedOrganizationNameUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/organization/SaveSelectedOrganizationNameUseCase.kt new file mode 100644 index 00000000..beaa8b52 --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/organization/SaveSelectedOrganizationNameUseCase.kt @@ -0,0 +1,12 @@ +package com.sixkids.domain.usecase.organization + +import com.sixkids.domain.repository.OrganizationRepository +import javax.inject.Inject + +class SaveSelectedOrganizationNameUseCase @Inject constructor( + private val organizationRepository: OrganizationRepository +){ + suspend operator fun invoke(organizationName: String) { + organizationRepository.saveSelectedOrganizationName(organizationName) + } +} \ No newline at end of file diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/organization/UpdateClassNameUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/organization/UpdateClassNameUseCase.kt new file mode 100644 index 00000000..de5b04ee --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/organization/UpdateClassNameUseCase.kt @@ -0,0 +1,12 @@ +package com.sixkids.domain.usecase.organization + +import com.sixkids.domain.repository.OrganizationRepository +import javax.inject.Inject + +class UpdateClassNameUseCase @Inject constructor( + private val organizationRepository: OrganizationRepository +) { + suspend operator fun invoke(organizationId: Int, name: String) = runCatching{ + organizationRepository.updateOrganization(organizationId, name) + } +} \ No newline at end of file diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/post/DeletePostUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/post/DeletePostUseCase.kt new file mode 100644 index 00000000..566b2cd7 --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/post/DeletePostUseCase.kt @@ -0,0 +1,13 @@ +package com.sixkids.domain.usecase.post + +import com.sixkids.domain.repository.PostRepository +import javax.inject.Inject + +class DeletePostUseCase @Inject constructor( + private val postRepository: PostRepository + +){ + suspend operator fun invoke(postId: Long) = runCatching { + postRepository.deletePost(postId) + } +} \ No newline at end of file diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/post/GetPostDetailUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/post/GetPostDetailUseCase.kt new file mode 100644 index 00000000..029a73a9 --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/post/GetPostDetailUseCase.kt @@ -0,0 +1,12 @@ +package com.sixkids.domain.usecase.post + +import com.sixkids.domain.repository.PostRepository +import javax.inject.Inject + +class GetPostDetailUseCase @Inject constructor( + private val postRepository: PostRepository +) { + suspend operator fun invoke(postId: Long) = runCatching { + postRepository.getPostDetail(postId) + } +} \ No newline at end of file diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/post/GetPostListUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/post/GetPostListUseCase.kt new file mode 100644 index 00000000..43d134d5 --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/post/GetPostListUseCase.kt @@ -0,0 +1,18 @@ +package com.sixkids.domain.usecase.post + +import com.sixkids.domain.repository.PostRepository +import javax.inject.Inject + +class GetPostListUseCase @Inject constructor( + private val postRepository: PostRepository +) { + suspend operator fun invoke( + organizationId: Int, + memberId: Int? = null, + postCategory: String + ) = postRepository.getPosts( + organizationId, + memberId, + postCategory + ) +} \ No newline at end of file diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/post/NewPostUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/post/NewPostUseCase.kt new file mode 100644 index 00000000..ec12d853 --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/post/NewPostUseCase.kt @@ -0,0 +1,28 @@ +package com.sixkids.domain.usecase.post + +import com.sixkids.domain.repository.PostRepository +import com.sixkids.model.Post +import java.io.File +import javax.inject.Inject + +class NewPostUseCase @Inject constructor( + private val postRepository: PostRepository +){ + suspend operator fun invoke( + organizationId: Long, + title: String, + content: String, + secretStatus: Boolean, + postCategory: String, + file: File? + ) = runCatching { + postRepository.createPost( + organizationId, + title, + content, + secretStatus, + postCategory, + file + ) + } +} \ No newline at end of file diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/post/ReportPostUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/post/ReportPostUseCase.kt new file mode 100644 index 00000000..7151d311 --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/post/ReportPostUseCase.kt @@ -0,0 +1,12 @@ +package com.sixkids.domain.usecase.post + +import com.sixkids.domain.repository.PostRepository +import javax.inject.Inject + +class ReportPostUseCase @Inject constructor( + private val postRepository: PostRepository +) { + suspend operator fun invoke(postId: Long) = runCatching { + postRepository.reportPost(postId) + } +} \ No newline at end of file diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/post/UpdatePostUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/post/UpdatePostUseCase.kt new file mode 100644 index 00000000..dcc3c559 --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/post/UpdatePostUseCase.kt @@ -0,0 +1,27 @@ +package com.sixkids.domain.usecase.post + +import com.sixkids.domain.repository.PostRepository +import java.io.File +import javax.inject.Inject + +class UpdatePostUseCase @Inject constructor( + private val postRepository: PostRepository +) { + suspend operator fun invoke( + postId: Long, + title: String, + content: String, + secretStatus: Boolean, + postCategory: String, + file: File? + ) = runCatching { + postRepository.updatePost( + postId, + title, + content, + secretStatus, + postCategory, + file + ) + } +} \ No newline at end of file diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/relay/CreateRelayUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/relay/CreateRelayUseCase.kt new file mode 100644 index 00000000..22b3dd58 --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/relay/CreateRelayUseCase.kt @@ -0,0 +1,12 @@ +package com.sixkids.domain.usecase.relay + +import com.sixkids.domain.repository.RelayRepository +import javax.inject.Inject + +class CreateRelayUseCase @Inject constructor( + private val relayRepository: RelayRepository +){ + suspend operator fun invoke(organizationId: Int, question: String) = runCatching { + relayRepository.createRelay(organizationId, question) + } +} \ No newline at end of file diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/relay/GetRelayDetailUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/relay/GetRelayDetailUseCase.kt new file mode 100644 index 00000000..444a91a6 --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/relay/GetRelayDetailUseCase.kt @@ -0,0 +1,12 @@ +package com.sixkids.domain.usecase.relay + +import com.sixkids.domain.repository.RelayRepository +import javax.inject.Inject + +class GetRelayDetailUseCase @Inject constructor( + private val relayRepository: RelayRepository +){ + suspend operator fun invoke(relayId: Long) = runCatching { + relayRepository.getRelayDetail(relayId) + } +} \ No newline at end of file diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/relay/GetRelayHistoryUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/relay/GetRelayHistoryUseCase.kt new file mode 100644 index 00000000..2a423d1d --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/relay/GetRelayHistoryUseCase.kt @@ -0,0 +1,14 @@ +package com.sixkids.domain.usecase.relay + +import com.sixkids.domain.repository.RelayRepository +import javax.inject.Inject + +class GetRelayHistoryUseCase @Inject constructor( + private val relayRepository: RelayRepository) +{ + + suspend operator fun invoke( + organizationId: Int, + memberId: Int? = null + ) = relayRepository.getRelayHistory(organizationId, memberId) +} \ No newline at end of file diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/relay/GetRelayQuestionUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/relay/GetRelayQuestionUseCase.kt new file mode 100644 index 00000000..232d8b2e --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/relay/GetRelayQuestionUseCase.kt @@ -0,0 +1,12 @@ +package com.sixkids.domain.usecase.relay + +import com.sixkids.domain.repository.RelayRepository +import javax.inject.Inject + +class GetRelayQuestionUseCase @Inject constructor( + private val relayRepository: RelayRepository +){ + suspend operator fun invoke(relayId: Long) = runCatching { + relayRepository.getRelayQuestion(relayId) + } +} \ No newline at end of file diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/relay/GetRunningRelayUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/relay/GetRunningRelayUseCase.kt new file mode 100644 index 00000000..a30f05da --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/relay/GetRunningRelayUseCase.kt @@ -0,0 +1,12 @@ +package com.sixkids.domain.usecase.relay + +import com.sixkids.domain.repository.RelayRepository +import javax.inject.Inject + +class GetRunningRelayUseCase @Inject constructor( + private val relayRepository: RelayRepository +){ + suspend operator fun invoke(organizationId: Long) = runCatching { + relayRepository.getRunningRelay(organizationId) + } +} \ No newline at end of file diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/relay/ReceiveRelayUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/relay/ReceiveRelayUseCase.kt new file mode 100644 index 00000000..26e9e6c4 --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/relay/ReceiveRelayUseCase.kt @@ -0,0 +1,16 @@ +package com.sixkids.domain.usecase.relay + +import com.sixkids.domain.repository.RelayRepository +import javax.inject.Inject + +class ReceiveRelayUseCase @Inject constructor( + private val relayRepository: RelayRepository +){ + suspend operator fun invoke( + relayId: Int, + senderId: Long, + question: String + ) = runCatching { + relayRepository.receiveRelay(relayId, senderId, question) + } +} \ No newline at end of file diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/relay/SendRelayUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/relay/SendRelayUseCase.kt new file mode 100644 index 00000000..a0e14e69 --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/relay/SendRelayUseCase.kt @@ -0,0 +1,12 @@ +package com.sixkids.domain.usecase.relay + +import com.sixkids.domain.repository.RelayRepository +import javax.inject.Inject + +class SendRelayUseCase @Inject constructor( + private val relayRepository: RelayRepository +){ + suspend operator fun invoke(relayId: Int) = runCatching { + relayRepository.sendRelay(relayId) + } +} \ No newline at end of file diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/user/AutoSignInUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/user/AutoSignInUseCase.kt new file mode 100644 index 00000000..bff34251 --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/user/AutoSignInUseCase.kt @@ -0,0 +1,12 @@ +package com.sixkids.domain.usecase.user + +import com.sixkids.domain.repository.UserRepository +import javax.inject.Inject + +class AutoSignInUseCase @Inject constructor( + private val userRepository: UserRepository +){ + suspend operator fun invoke() = runCatching { + userRepository.autoSignIn() + } +} \ No newline at end of file diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/user/GetATKUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/user/GetATKUseCase.kt new file mode 100644 index 00000000..f7bf9794 --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/user/GetATKUseCase.kt @@ -0,0 +1,12 @@ +package com.sixkids.domain.usecase.user + +import com.sixkids.domain.repository.TokenRepository +import javax.inject.Inject + +class GetATKUseCase @Inject constructor( + private val tokenRepository: TokenRepository +){ + suspend operator fun invoke() = runCatching { + tokenRepository.getAccessToken() + } +} \ No newline at end of file diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/user/GetMemberSimpleUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/user/GetMemberSimpleUseCase.kt new file mode 100644 index 00000000..f6fa833b --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/user/GetMemberSimpleUseCase.kt @@ -0,0 +1,12 @@ +package com.sixkids.domain.usecase.user + +import com.sixkids.domain.repository.UserRepository +import javax.inject.Inject + +class GetMemberSimpleUseCase @Inject constructor( + private val userRepository: UserRepository +){ + suspend operator fun invoke(id: Long) = runCatching { + userRepository.getMemberSimpleInfo(id) + } +} diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/user/GetRoleUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/user/GetRoleUseCase.kt new file mode 100644 index 00000000..de284de9 --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/user/GetRoleUseCase.kt @@ -0,0 +1,12 @@ +package com.sixkids.domain.usecase.user + +import com.sixkids.domain.repository.UserRepository +import javax.inject.Inject + +class GetRoleUseCase @Inject constructor( + private val userRepository: UserRepository +) { + suspend operator fun invoke() = runCatching { + userRepository.getRole() + } +} \ No newline at end of file diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/user/GetStudentHomeInfoUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/user/GetStudentHomeInfoUseCase.kt new file mode 100644 index 00000000..e8a97405 --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/user/GetStudentHomeInfoUseCase.kt @@ -0,0 +1,12 @@ +package com.sixkids.domain.usecase.user + +import com.sixkids.domain.repository.UserRepository +import javax.inject.Inject + +class GetStudentHomeInfoUseCase @Inject constructor( + private val userRepository: UserRepository +) { + suspend operator fun invoke(organizationId: Long) = runCatching { + userRepository.getStudentHomeInfo(organizationId) + } +} \ No newline at end of file diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/user/GetUserInfoUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/user/GetUserInfoUseCase.kt new file mode 100644 index 00000000..bf468f72 --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/user/GetUserInfoUseCase.kt @@ -0,0 +1,12 @@ +package com.sixkids.domain.usecase.user + +import com.sixkids.domain.repository.UserRepository +import javax.inject.Inject + +class GetUserInfoUseCase @Inject constructor( + private val userRepository: UserRepository +){ + suspend operator fun invoke() = runCatching { + userRepository.getMemberInfo() + } +} \ No newline at end of file diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/user/LoadUserInfoUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/user/LoadUserInfoUseCase.kt new file mode 100644 index 00000000..8a60f426 --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/user/LoadUserInfoUseCase.kt @@ -0,0 +1,12 @@ +package com.sixkids.domain.usecase.user + +import com.sixkids.domain.repository.UserRepository +import javax.inject.Inject + +class LoadUserInfoUseCase @Inject constructor( + private val userRepository: UserRepository +){ + suspend operator fun invoke() = runCatching { + userRepository.loadUserInfo() + } +} \ No newline at end of file diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/user/SignInUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/user/SignInUseCase.kt new file mode 100644 index 00000000..257e035b --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/user/SignInUseCase.kt @@ -0,0 +1,12 @@ +package com.sixkids.domain.usecase.user + +import com.sixkids.domain.repository.UserRepository +import javax.inject.Inject + +class SignInUseCase @Inject constructor( + private val userRepository: UserRepository +){ + suspend operator fun invoke(idToken: String) = runCatching { + userRepository.signIn(idToken) + } +} \ No newline at end of file diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/user/SignOutUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/user/SignOutUseCase.kt new file mode 100644 index 00000000..7f41c1fa --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/user/SignOutUseCase.kt @@ -0,0 +1,10 @@ +package com.sixkids.domain.usecase.user + +import com.sixkids.domain.repository.UserRepository +import javax.inject.Inject + +class SignOutUseCase @Inject constructor( + private val userRepository: UserRepository +){ + suspend operator fun invoke() = userRepository.signOut() +} \ No newline at end of file diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/user/SignUpUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/user/SignUpUseCase.kt new file mode 100644 index 00000000..4af74dea --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/user/SignUpUseCase.kt @@ -0,0 +1,13 @@ +package com.sixkids.domain.usecase.user + +import com.sixkids.domain.repository.UserRepository +import java.io.File +import javax.inject.Inject + +class SignUpUseCase @Inject constructor( + private val userRepository: UserRepository +){ + suspend operator fun invoke(file: File?, defaultImage: Int, role: String) = runCatching { + userRepository.signUp(file, defaultImage, role) + } +} diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/user/UpdateFCMTokenUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/user/UpdateFCMTokenUseCase.kt new file mode 100644 index 00000000..c5ec4dfd --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/user/UpdateFCMTokenUseCase.kt @@ -0,0 +1,12 @@ +package com.sixkids.domain.usecase.user + +import com.sixkids.domain.repository.UserRepository +import javax.inject.Inject + +class UpdateFCMTokenUseCase @Inject constructor( + private val userRepository: UserRepository +) { + suspend operator fun invoke(token: String) = runCatching { + userRepository.updateFCMToken(token) + } +} diff --git a/android/domain/src/main/java/com/sixkids/domain/usecase/user/UpdateUserProfilePhotoUseCase.kt b/android/domain/src/main/java/com/sixkids/domain/usecase/user/UpdateUserProfilePhotoUseCase.kt new file mode 100644 index 00000000..fef65e83 --- /dev/null +++ b/android/domain/src/main/java/com/sixkids/domain/usecase/user/UpdateUserProfilePhotoUseCase.kt @@ -0,0 +1,13 @@ +package com.sixkids.domain.usecase.user + +import com.sixkids.domain.repository.UserRepository +import java.io.File +import javax.inject.Inject + +class UpdateUserProfilePhotoUseCase @Inject constructor( + private val userRepository: UserRepository +){ + suspend operator fun invoke(file: File?, defaultImage: Int) = runCatching { + userRepository.updateMemberProfilePhoto(file, defaultImage) + } +} \ No newline at end of file diff --git a/android/feature/navigator/build.gradle.kts b/android/feature/navigator/build.gradle.kts index b269b498..4f4dc89b 100644 --- a/android/feature/navigator/build.gradle.kts +++ b/android/feature/navigator/build.gradle.kts @@ -9,4 +9,17 @@ android { dependencies { implementation(projects.feature.teacher.home) implementation(projects.feature.teacher.board) + implementation(projects.feature.teacher.manageclass) + implementation(projects.feature.teacher.challenge) + implementation(projects.feature.signin) + implementation(projects.feature.teacher.managestudent) + implementation(projects.feature.teacher.main) + implementation(projects.feature.student.board) + implementation(projects.feature.student.home) + implementation(projects.feature.student.challenge) + implementation(projects.feature.student.relay) + implementation(projects.feature.student.main) + implementation(projects.feature.teacher.relay) + + implementation(libs.permissions) } diff --git a/android/feature/navigator/src/main/AndroidManifest.xml b/android/feature/navigator/src/main/AndroidManifest.xml index 999fd910..42e0c67a 100644 --- a/android/feature/navigator/src/main/AndroidManifest.xml +++ b/android/feature/navigator/src/main/AndroidManifest.xml @@ -1,9 +1,13 @@ + + - + android:theme="@style/Theme.Ulban" + android:windowSoftInputMode="adjustResize"> diff --git a/android/feature/navigator/src/main/java/com/sixkids/feature/navigator/MainActivity.kt b/android/feature/navigator/src/main/java/com/sixkids/feature/navigator/MainActivity.kt index 2f0bfd1b..cc0713e9 100644 --- a/android/feature/navigator/src/main/java/com/sixkids/feature/navigator/MainActivity.kt +++ b/android/feature/navigator/src/main/java/com/sixkids/feature/navigator/MainActivity.kt @@ -12,8 +12,8 @@ class MainActivity : ComponentActivity() { super.onCreate(savedInstanceState) setContent { UlbanTheme { - MainScreen() + MainScreen() } } } -} \ No newline at end of file +} diff --git a/android/feature/navigator/src/main/java/com/sixkids/feature/navigator/MainContract.kt b/android/feature/navigator/src/main/java/com/sixkids/feature/navigator/MainContract.kt new file mode 100644 index 00000000..77e5c589 --- /dev/null +++ b/android/feature/navigator/src/main/java/com/sixkids/feature/navigator/MainContract.kt @@ -0,0 +1,14 @@ +package com.sixkids.feature.navigator + +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState + +data class MainState( + val snackbarToken: SnackbarToken = SnackbarToken(), + val snackbarVisible: Boolean = false, +) : UiState + +sealed interface MainSideEffect : SideEffect { + data object ShowSnackbar : MainSideEffect +} diff --git a/android/feature/navigator/src/main/java/com/sixkids/feature/navigator/MainNavigationTab.kt b/android/feature/navigator/src/main/java/com/sixkids/feature/navigator/MainNavigationTab.kt index 8dcb4c0d..80b25269 100644 --- a/android/feature/navigator/src/main/java/com/sixkids/feature/navigator/MainNavigationTab.kt +++ b/android/feature/navigator/src/main/java/com/sixkids/feature/navigator/MainNavigationTab.kt @@ -8,8 +8,14 @@ import com.sixkids.designsystem.theme.Green import com.sixkids.designsystem.theme.Purple import com.sixkids.designsystem.theme.Red import com.sixkids.navigator.R +import com.sixkids.student.board.navigation.StudentBoardRoute +import com.sixkids.student.home.navigation.StudentHomeRoute +import com.sixkids.student.navigation.ChallengeRoute +import com.sixkids.student.relay.navigation.RelayRoute import com.sixkids.teacher.board.navigation.BoardRoute import com.sixkids.teacher.home.navigation.HomeRoute +import com.sixkids.teacher.manageclass.navigation.ManageClassRoute +import com.sixkids.teacher.managestudent.navigation.ManageStudentRoute enum class MainNavigationTab( @@ -25,7 +31,7 @@ enum class MainNavigationTab( route = HomeRoute.defaultRoute, ), BOARD( - iconId = R.drawable.board, + iconId = R.drawable.ic_board, iconTint = Blue, labelId = R.string.bottom_navigation_tab_label_board, route = BoardRoute.defaultRoute, @@ -34,13 +40,37 @@ enum class MainNavigationTab( iconId = R.drawable.manage_student, iconTint = Purple, labelId = R.string.bottom_navigation_tab_label_manage_student, - route = "StatisticsRoute.route", + route = ManageStudentRoute.defaultRoute ), MANAGE_CLASS( iconId = R.drawable.manage_class, iconTint = Green, labelId = R.string.bottom_navigation_tab_label_manage_class, - route = "CommunityRoute.route", + route = ManageClassRoute.defaultRoute, + ), + STUDENT_HOME( + iconId = R.drawable.home, + iconTint = Red, + labelId = R.string.bottom_navigation_tab_label_home, + route = StudentHomeRoute.defaultRoute, + ), + STUDENT_BOARD( + iconId = R.drawable.ic_board, + iconTint = Blue, + labelId = R.string.bottom_navigation_tab_label_board, + route = StudentBoardRoute.defaultRoute, + ), + STUDENT_RELAY( + iconId = R.drawable.folded_hands, + iconTint = Purple, + labelId = R.string.bottom_navigation_tab_label_relay, + route = RelayRoute.defaultRoute, + ), + STUDENT_CHALLENGE( + iconId = R.drawable.home_rocket, + iconTint = Green, + labelId = R.string.bottom_navigation_tab_label_challenge, + route = ChallengeRoute.defaultRoute, ) ; @@ -53,4 +83,4 @@ enum class MainNavigationTab( return entries.find { it.route == route } } } -} \ No newline at end of file +} diff --git a/android/feature/navigator/src/main/java/com/sixkids/feature/navigator/MainNavigator.kt b/android/feature/navigator/src/main/java/com/sixkids/feature/navigator/MainNavigator.kt index 2bb18042..bbe5c76c 100644 --- a/android/feature/navigator/src/main/java/com/sixkids/feature/navigator/MainNavigator.kt +++ b/android/feature/navigator/src/main/java/com/sixkids/feature/navigator/MainNavigator.kt @@ -1,6 +1,8 @@ package com.sixkids.feature.navigator import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.navigation.NavDestination import androidx.navigation.NavHostController @@ -8,15 +10,77 @@ import androidx.navigation.NavOptions import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.navigation.navOptions +import com.sixkids.feature.signin.navigation.SignInRoute +import com.sixkids.feature.signin.navigation.navigateSignIn +import com.sixkids.feature.signin.navigation.navigateSignUp +import com.sixkids.feature.signin.navigation.navigateSignUpPhoto +import com.sixkids.model.GroupType +import com.sixkids.model.MemberSimple +import com.sixkids.student.board.navigation.StudentBoardRoute +import com.sixkids.student.board.navigation.navigateStudentBoard +import com.sixkids.student.board.navigation.navigateStudentBoardDetail +import com.sixkids.student.board.navigation.navigateStudentBoardWrite +import com.sixkids.student.home.navigation.StudentHomeRoute +import com.sixkids.student.home.navigation.navigateStudentAnnounceDetail +import com.sixkids.student.home.navigation.navigateStudentAnnounceList +import com.sixkids.student.home.navigation.navigateStudentChatting +import com.sixkids.student.home.navigation.navigateStudentGreetingReceiver +import com.sixkids.student.home.navigation.navigateStudentGreetingSender +import com.sixkids.student.home.navigation.navigateStudentHome +import com.sixkids.student.main.navigation.navigateJoinOrganization +import com.sixkids.student.main.navigation.navigateStudentOrganizationList +import com.sixkids.student.main.navigation.navigateStudentProfile +import com.sixkids.student.navigation.navigatePopupToStudentChallengeHistory +import com.sixkids.student.navigation.navigateStudentChallengeHistory +import com.sixkids.student.navigation.navigateStudentGroupCreate +import com.sixkids.student.navigation.navigateStudentGroupJoin +import com.sixkids.student.navigation.navigateStudentMatchedGroupCreate +import com.sixkids.student.relay.navigation.RelayRoute +import com.sixkids.student.relay.navigation.navigateStudentRelayAnswer +import com.sixkids.student.relay.navigation.navigateStudentRelayCreate +import com.sixkids.student.relay.navigation.navigateStudentRelayCreateResult +import com.sixkids.student.relay.navigation.navigateStudentRelayDetail +import com.sixkids.student.relay.navigation.navigateStudentRelayHistory +import com.sixkids.student.relay.navigation.navigateStudentRelayJoin +import com.sixkids.student.relay.navigation.navigateStudentRelayTaggingReceiver +import com.sixkids.student.relay.navigation.navigateStudentRelayTaggingSender +import com.sixkids.teacher.board.navigation.BoardRoute +import com.sixkids.teacher.board.navigation.navigateAnnounce +import com.sixkids.teacher.board.navigation.navigateAnnounceDetail +import com.sixkids.teacher.board.navigation.navigateAnnounceWrite import com.sixkids.teacher.board.navigation.navigateBoard +import com.sixkids.teacher.board.navigation.navigateChatting +import com.sixkids.teacher.board.navigation.navigatePost +import com.sixkids.teacher.board.navigation.navigatePostWrite +import com.sixkids.teacher.board.navigation.navigatePostDetail +import com.sixkids.teacher.challenge.navigation.navigateChallengeCreatedResult +import com.sixkids.teacher.challenge.navigation.navigateChallengeDetail +import com.sixkids.teacher.challenge.navigation.navigateChallengeHistory +import com.sixkids.teacher.challenge.navigation.navigateCreateChallenge +import com.sixkids.teacher.challenge.navigation.navigatePopupToHistory import com.sixkids.teacher.home.navigation.HomeRoute import com.sixkids.teacher.home.navigation.navigateHome import com.sixkids.teacher.home.navigation.navigateRank +import com.sixkids.teacher.main.navigation.navigateNewOrganization +import com.sixkids.teacher.main.navigation.navigateProfile +import com.sixkids.teacher.main.navigation.navigateTeacherOrganizationList +import com.sixkids.teacher.manageclass.navigation.ManageClassRoute +import com.sixkids.teacher.manageclass.navigation.navigateChattingFilter +import com.sixkids.teacher.manageclass.navigation.navigateClassSetting +import com.sixkids.teacher.manageclass.navigation.navigateInvite +import com.sixkids.teacher.manageclass.navigation.navigateManageClass +import com.sixkids.teacher.manageclass.navigation.navigateStatistics +import com.sixkids.teacher.managestudent.navigation.ManageStudentRoute +import com.sixkids.teacher.managestudent.navigation.navigateManageStudent +import com.sixkids.teacher.managestudent.navigation.navigateStudentDetail +import com.sixkids.teacher.relay.navigation.navigateTeacherRelayDetail +import com.sixkids.teacher.relay.navigation.navigateTeacherRelayHistory class MainNavigator( val navController: NavHostController, ) { - val startDestination = HomeRoute.defaultRoute + var bottomTabItems: State>? = null + val startDestination = SignInRoute.defaultRoute private val currentDestination: NavDestination? @Composable get() = navController .currentBackStackEntryAsState().value?.destination @@ -26,7 +90,7 @@ class MainNavigator( ?.let { MainNavigationTab.find(it) } fun navigate(tab: MainNavigationTab) { - val navOptions = navOptions { + val teacherNavOptions = navOptions { popUpTo(HomeRoute.defaultRoute) { saveState = true } @@ -34,29 +98,328 @@ class MainNavigator( restoreState = true } + val studentNavOptions = navOptions { + popUpTo(StudentHomeRoute.defaultRoute) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + when (tab) { - MainNavigationTab.HOME -> navController.navigateHome(navOptions) - MainNavigationTab.BOARD -> navController.navigateBoard(navOptions) - MainNavigationTab.MANAGE_STUDENT -> {} - MainNavigationTab.MANAGE_CLASS -> {} + // 선생님 바텀 네비게이션 탭 + MainNavigationTab.HOME -> navController.navigateHome(teacherNavOptions) + MainNavigationTab.BOARD -> navController.navigateBoard(teacherNavOptions) + MainNavigationTab.MANAGE_STUDENT -> navController.navigateManageStudent(teacherNavOptions) + MainNavigationTab.MANAGE_CLASS -> navController.navigateManageClass(teacherNavOptions) + // 학생 바텀 네비게이션 탭 + MainNavigationTab.STUDENT_HOME -> navController.navigateStudentHome(studentNavOptions) + MainNavigationTab.STUDENT_BOARD -> navController.navigateStudentBoard(studentNavOptions) + MainNavigationTab.STUDENT_RELAY -> navController.navigateStudentRelayHistory(studentNavOptions) + MainNavigationTab.STUDENT_CHALLENGE -> navController.navigateStudentChallengeHistory(studentNavOptions) + } + } + + /** + * Board Navigation + */ + + fun navigateBoard() { + navController.navigate(BoardRoute.defaultRoute) { + popUpTo(navController.graph.id) { + inclusive = true + } } } + fun navigatePost() { + navController.navigatePost() + } + + fun navigatePostWrite() { + navController.navigatePostWrite() + } + + fun navigatePostDetail(postId: Long) { + navController.navigatePostDetail(postId) + } + + fun navigateAnnounce() { + navController.navigateAnnounce() + } + + fun navigateAnnounceWrite() { + navController.navigateAnnounceWrite() + } + + fun navigateAnnounceDetail(announceId: Long) { + navController.navigateAnnounceDetail(announceId) + } + + /** + * Student Main Navigation + */ + fun navigateStudentOrganizationList(){ + navController.navigateStudentOrganizationList() + } + + fun navigateStudentProfile(){ + navController.navigateStudentProfile() + } + + fun navigateJoinOrganization() { + navController.navigateJoinOrganization() + } + + + /** + * Teacher Manage Student Navigation + */ + fun navigateManageStudentDetail(studentId: Long) { + navController.navigateStudentDetail(studentId) + } + /** * Home Navigation */ - fun navigateHome(navOptions: NavOptions) { - navController.navigate(HomeRoute.defaultRoute){ - popUpTo(navController.graph.id){ + fun navigateHome() { + bottomTabItems = teacherTab() // 바텀 네비게이션 탭 초기화 + navController.navigate(HomeRoute.defaultRoute) + } + + fun navigateRank() { + navController.navigateRank() + } + + /** + * Manage Class Navigation + */ + fun navigateManageClass(navOptions: NavOptions) { + navController.navigate(ManageClassRoute.defaultRoute) { + popUpTo(navController.graph.id) { inclusive = true } } } - fun navigateRank(){ + fun navigateChattingFilter() { + navController.navigateChattingFilter() + } + + fun navigateClassInvite() { + navController.navigateInvite() + } + + fun navigateClassSetting() { + navController.navigateClassSetting() + } + + fun navigateClassStatistics() { + navController.navigateStatistics() + } + + /** + * Manage Student Navigation + */ + fun navigateManageStudent() { + navController.navigateManageStudent(navOptions { + popUpTo(ManageStudentRoute.defaultRoute) { + inclusive = true + } + }) + } + + /** + * Student Home Navigation + */ + fun navigateStudentHome() { + bottomTabItems = studentTab() // 바텀 네비게이션 탭 초기화 + navController.navigateStudentHome(navOptions{ + popUpTo(StudentHomeRoute.defaultRoute){ + inclusive = true + } + }) + } + + fun navigateStudentAnnounceList() { + navController.navigateStudentAnnounceList() + } + + fun navigateStudentAnnounceDetail(announceId: Long) { + navController.navigateStudentAnnounceDetail(announceId) + } + + fun navigateStudentRank() { navController.navigateRank() } + fun navigateGreetingSender(){ + navController.navigateStudentGreetingSender() + } + + fun navigateGreetingReceiver(){ + navController.navigateStudentGreetingReceiver() + } + + /** + * Student Group Navigation + */ + //TODO : memberId 추가 + + fun navigatePopupToStudentGroupHistory() { + navController.navigatePopupToStudentChallengeHistory() + } + + fun navigateStudentGroupCreate(challengeId: Long, groupType: GroupType) { + navController.navigateStudentGroupCreate( + challengeId = challengeId, + groupType = groupType + ) + } + + fun navigateStudentMatchedGroupCreate(challengeId: Long, members: List) { + navController.navigateStudentMatchedGroupCreate( + challengeId = challengeId, + members = members + ) + } + + fun navigateStudentGroupJoin(memberId: Long) { + navController.navigateStudentGroupJoin() + } + + /** + * SignIn Navigation + */ + fun navigateSignIn() { + navController.navigateSignIn() + } + + fun navigateSignUp() { + navController.navigateSignUp() + } + + fun navigateSignUpPhoto(isTeacher: Boolean) { + navController.navigateSignUpPhoto(isTeacher) + } + + /** + * Student Relay Navigation + */ + fun navigateStudentRelayHistory() { + navController.navigate(RelayRoute.defaultRoute) { + popUpTo(RelayRoute.defaultRoute) { + inclusive = true + } + } + } + + fun navigateStudentRelayDetail(relayId: Long) { + navController.navigateStudentRelayDetail(relayId) + } + + fun navigateStudentRelayCreate() { + navController.navigateStudentRelayCreate() + } + + fun navigateStudentRelayJoin() { + navController.navigateStudentRelayJoin() + } + + fun navigateStudentRelayCreateResult() { + navController.navigateStudentRelayCreateResult() + } + + fun navigateStudentRelayAnswer(relayId: Long) { + navController.navigateStudentRelayAnswer(relayId) + } + + fun navigateStudentRelayTaggingSender(relayId: Long, question: String) { + navController.navigateStudentRelayTaggingSender(relayId, question) + } + + fun navigateStudentRelayTaggingReceiver(relayId: Long) { + navController.navigateStudentRelayTaggingReceiver(relayId) + } + + /** + * Challenge Navigation + */ + fun navigateChallengeHistory() { + navController.navigateChallengeHistory() + } + + fun navigateChallengeDetail(challengeId: Long, groupId: Long?) { + navController.navigateChallengeDetail(challengeId, groupId) + } + + fun navigatePopupToHistory() { + navController.navigatePopupToHistory() + } + + fun navigateCreateChallenge() { + navController.navigateCreateChallenge() + } + + fun navigateChallengeCreatedResult(challengeId: Long, title: String) { + navController.navigateChallengeCreatedResult(challengeId, title) + } + + /** + * Teacher Relay + */ + fun navigateTeacherRelayHistory(){ + navController.navigateTeacherRelayHistory() + } + + fun navigateTeacherRelayDetail(relayId: Long){ + navController.navigateTeacherRelayDetail(relayId) + } + + fun navigateTeacherOrganizationList() { + bottomTabItems = teacherTab() // 바텀 네비게이션 탭 초기화 + navController.navigateTeacherOrganizationList() + } + + fun navigateNewOrganization() { + navController.navigateNewOrganization() + } + + fun navigateProfile() { + navController.navigateProfile() + } + + fun popBackStack() { + navController.popBackStack() + } + + fun navigateChatting() { + navController.navigateChatting() + } + + /** + * Student Board Navigation + */ + fun navigateStudentBoard() { + navController.navigate(StudentBoardRoute.defaultRoute) { + popUpTo(navController.graph.id) { + inclusive = true + } + } + } + + fun navigateStudentBoardWrite() { + navController.navigateStudentBoardWrite() + } + + fun navigateStudentBoardDetail(postId: Long) { + navController.navigateStudentBoardDetail(postId) + } + + fun navigateStudentChatting() { + navController.navigateStudentChatting() + } + @Composable fun shouldShowBottomBar(): Boolean { val currentRoute = currentDestination?.route ?: return false @@ -69,4 +432,26 @@ internal fun rememberMainNavigator( navController: NavHostController = rememberNavController(), ): MainNavigator = remember(navController) { MainNavigator(navController) -} \ No newline at end of file +} + +internal fun teacherTab(): State>{ + return mutableStateOf( + listOf( + MainNavigationTab.HOME, + MainNavigationTab.BOARD, + MainNavigationTab.MANAGE_STUDENT, + MainNavigationTab.MANAGE_CLASS + ) + ) +} + +internal fun studentTab(): State>{ + return mutableStateOf( + listOf( + MainNavigationTab.STUDENT_HOME, + MainNavigationTab.STUDENT_BOARD, + MainNavigationTab.STUDENT_RELAY, + MainNavigationTab.STUDENT_CHALLENGE + ) + ) +} diff --git a/android/feature/navigator/src/main/java/com/sixkids/feature/navigator/MainScreen.kt b/android/feature/navigator/src/main/java/com/sixkids/feature/navigator/MainScreen.kt index 5bdde665..8fb5464f 100644 --- a/android/feature/navigator/src/main/java/com/sixkids/feature/navigator/MainScreen.kt +++ b/android/feature/navigator/src/main/java/com/sixkids/feature/navigator/MainScreen.kt @@ -1,13 +1,15 @@ package com.sixkids.feature.navigator +import android.os.Build +import androidx.annotation.RequiresApi import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideIn import androidx.compose.animation.slideOut import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.material3.Icon import androidx.compose.material3.NavigationBar @@ -17,6 +19,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -24,17 +27,51 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.IntOffset +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.compose.NavHost +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import com.sixkids.designsystem.component.snackbar.UlbanSnackbar import com.sixkids.designsystem.theme.Cream +import com.sixkids.feature.signin.navigation.signInNavGraph +import com.sixkids.student.board.navigation.studentBoardNavGraph +import com.sixkids.student.home.navigation.studentHomeNavGraph +import com.sixkids.student.main.navigation.studentOrganizationListNavGraph +import com.sixkids.student.navigation.studentChallengeNavGraph +import com.sixkids.student.navigation.studentGroupNavGraph +import com.sixkids.student.relay.navigation.studentRelayNavGraph import com.sixkids.teacher.board.navigation.boardNavGraph +import com.sixkids.teacher.challenge.navigation.challengeNavGraph import com.sixkids.teacher.home.navigation.homeNavGraph +import com.sixkids.teacher.main.navigation.teacherOrganizationListNavGraph +import com.sixkids.teacher.manageclass.navigation.manageClassNavGraph +import com.sixkids.teacher.managestudent.navigation.manageStudentNavGraph +import com.sixkids.teacher.relay.navigation.teacherRelayNavGraph +import com.sixkids.ui.extension.collectWithLifecycle @Composable fun MainScreen( modifier: Modifier = Modifier, - // TODO viewmodel: MainViewModel, + viewModel: MainViewModel = hiltViewModel(), navigator: MainNavigator = rememberMainNavigator() ) { + + val uiState = viewModel.uiState.collectAsStateWithLifecycle().value + + viewModel.sideEffect.collectWithLifecycle { + when (it) { + is MainSideEffect.ShowSnackbar -> { + viewModel.onShowSnackbar(uiState.snackbarToken) + } + } + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + RequestNotificationPermission() + } + Scaffold( bottomBar = { BottomNav( @@ -42,23 +79,159 @@ fun MainScreen( modifier = Modifier, selectedTab = navigator.currentTab ?: MainNavigationTab.HOME, itemClick = navigator::navigate, + bottomTavItems = navigator.bottomTabItems?.value ) } ) { innerPadding -> NavHost( navController = navigator.navController, startDestination = navigator.startDestination, - enterTransition = { EnterTransition.None }, - exitTransition = { ExitTransition.None }, + enterTransition = { fadeIn(animationSpec = tween(0)) }, + exitTransition = { fadeOut(animationSpec = tween(0)) }, ) { homeNavGraph( padding = innerPadding, navigateToRank = navigator::navigateRank, + navigateToChallenge = navigator::navigateChallengeHistory, + navigateToRelay = navigator::navigateTeacherRelayHistory, + navigateToQuiz = { } , + onShowSnackBar = viewModel::onShowSnackbar, ) boardNavGraph( padding = innerPadding, + navigateToPost = navigator::navigatePost, + navigateToPostDetail = navigator::navigatePostDetail, + onBackClick = navigator::popBackStack, + onShowSnackBar = viewModel::onShowSnackbar, + navigateToChatting = navigator::navigateChatting, + navigateToPostWrite = navigator::navigatePostWrite, + navigateToAnnounceDetail = navigator::navigateAnnounceDetail, + navigateToAnnounceWrite = navigator::navigateAnnounceWrite, + navigateToAnnounceList = navigator::navigateAnnounce, + ) + + challengeNavGraph( + navigateChallengeDetail = navigator::navigateChallengeDetail, + navigateCreateChallenge = navigator::navigateCreateChallenge, + navigateChallengeCreatedResult = navigator::navigateChallengeCreatedResult, + navigateChallengeHistory = navigator::navigatePopupToHistory, + handleException = viewModel::handleException, + showSnackbar = viewModel::onShowSnackbar, + navigateUp = navigator::popBackStack, + ) + + manageClassNavGraph( + padding = innerPadding, + onShowSnackBar = viewModel::onShowSnackbar, + navigateToClassSummary = navigator::navigateClassStatistics, + navigateToClassSetting = navigator::navigateClassSetting, + navigateToChattingFilter = navigator::navigateChattingFilter, + navigateToInvite = navigator::navigateClassInvite, + navigateBack = navigator::popBackStack, + ) + + manageStudentNavGraph( + padding = innerPadding, + navigateToStudentDetail = navigator::navigateManageStudentDetail, + handleException = viewModel::handleException, + ) + + signInNavGraph( + navigateToSignUp = navigator::navigateSignUp, + navigateSignUpPhoto = navigator::navigateSignUpPhoto, + navigateToHome = navigator::navigateHome, + onShowSnackBar = viewModel::onShowSnackbar, + onBackClick = navigator::popBackStack, + navigateToTeacherOrganizationList = navigator::navigateTeacherOrganizationList, + navigateToStudentOrganizationList = navigator::navigateStudentOrganizationList, + ) + + teacherOrganizationListNavGraph( + navigateToNewOrganization = navigator::navigateNewOrganization, + navigateToProfile = navigator::navigateProfile, + navigateToHome = navigator::navigateHome, + onShowSnackBar = viewModel::onShowSnackbar, + onBackClick = navigator::popBackStack, + navigateToSignIn = navigator::navigateSignIn, + ) + + studentHomeNavGraph( + padding = innerPadding, + onShowSnackbar = viewModel::onShowSnackbar, + navigateToStudentAnnounceList = navigator::navigateStudentAnnounceList, + navigateToStudentAnnounceDetail = navigator::navigateStudentAnnounceDetail, + navigateToTagHello = { }, + navigateToRank = navigator::navigateRank, + navigateToChatting = navigator::navigateStudentChatting, + navigateBack = navigator::popBackStack, + navigateToGreetingSender = navigator::navigateGreetingSender, + navigateToGreetingReceiver = navigator::navigateGreetingReceiver, + onBackClick = navigator::popBackStack, + ) + + studentChallengeNavGraph( + navigateChallengeDetail = navigator::navigateChallengeDetail, + navigateToCreateGroup = navigator::navigateStudentGroupCreate, + navigateToMatchedGroupCreate = navigator::navigateStudentMatchedGroupCreate, + navigateToJoinGroup = navigator::navigateStudentGroupJoin, + handleException = viewModel::handleException, + ) + + studentGroupNavGraph( + navigateToChallengeHistory = navigator::navigatePopupToStudentGroupHistory, + handleException = viewModel::handleException, + ) + + studentOrganizationListNavGraph( + navigateToJoinOrganization = navigator::navigateJoinOrganization, + navigateToProfile = navigator::navigateStudentProfile, + navigateToHome = navigator::navigateStudentHome, + navigateToSignIn = navigator::navigateSignIn, + onShowSnackBar = viewModel::onShowSnackbar, + onBackClick = navigator::popBackStack, + ) + + studentRelayNavGraph( + padding = innerPadding, + navigateRelayHistory = navigator::navigateStudentRelayHistory, + navigateRelayDetail = navigator::navigateStudentRelayDetail, + navigateCreateRelay = navigator::navigateStudentRelayCreate, + navigateCreateRelayResult = navigator::navigateStudentRelayCreateResult, + navigateJoinRelay = navigator::navigateStudentRelayJoin, + navigateAnswerRelay = navigator::navigateStudentRelayAnswer, + navigateTaggingSender = navigator::navigateStudentRelayTaggingSender, + navigateTaggingReceiver = navigator::navigateStudentRelayTaggingReceiver, + onShowSnackBar = viewModel::onShowSnackbar, + onBackClick = navigator::popBackStack, + handleException = viewModel::handleException + ) + + studentBoardNavGraph( + padding = innerPadding, + onShowSnackBar = viewModel::onShowSnackbar, + navigateToStudentBoardDetail = navigator::navigateStudentBoardDetail, + navigateToStudentBoardWrite = navigator::navigateStudentBoardWrite, + navigateBack = navigator::popBackStack, + ) + + teacherRelayNavGraph( + padding = innerPadding, + navigateRelayHistory = navigator::navigateTeacherRelayHistory, + navigateRelayDetail = navigator::navigateTeacherRelayDetail, + handleException = viewModel::handleException + ) + + } + with(uiState) { + UlbanSnackbar( + modifier = Modifier.padding(innerPadding), + visible = snackbarVisible, + message = snackbarToken.message, + actionIconId = snackbarToken.actionIcon, + actionButtonText = snackbarToken.actionButtonText, + onClickActionButton = snackbarToken.onClickActionButton, ) } } @@ -71,6 +244,7 @@ fun BottomNav( modifier: Modifier, itemClick: (MainNavigationTab) -> Unit = {}, selectedTab: MainNavigationTab, + bottomTavItems: List? = null ) { val selectedItem = rememberUpdatedState(newValue = selectedTab) @@ -88,7 +262,7 @@ fun BottomNav( modifier = modifier, containerColor = Cream, ) { - MainNavigationTab.entries.forEach { item -> + bottomTavItems?.forEach { item -> NavigationBarItem( icon = { Icon( @@ -113,4 +287,20 @@ fun BottomNav( } } } -} \ No newline at end of file +} + +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun RequestNotificationPermission() { + val notificationPermissionState = rememberPermissionState( + permission = android.Manifest.permission.POST_NOTIFICATIONS + ) + + LaunchedEffect(Unit) { + if (notificationPermissionState.status.isGranted.not()) { + notificationPermissionState.launchPermissionRequest() + } + } + +} diff --git a/android/feature/navigator/src/main/java/com/sixkids/feature/navigator/MainViewModel.kt b/android/feature/navigator/src/main/java/com/sixkids/feature/navigator/MainViewModel.kt new file mode 100644 index 00000000..745bf33a --- /dev/null +++ b/android/feature/navigator/src/main/java/com/sixkids/feature/navigator/MainViewModel.kt @@ -0,0 +1,47 @@ +package com.sixkids.feature.navigator + +import androidx.lifecycle.viewModelScope +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import javax.inject.Inject + +@HiltViewModel +class MainViewModel @Inject constructor( +) : BaseViewModel(MainState()) { + private val mutex = Mutex() + + fun onShowSnackbar(snackbarToken: SnackbarToken) { + viewModelScope.launch { + mutex.withLock { + intent { copy(snackbarToken = snackbarToken, snackbarVisible = true) } + delay(SHOW_TOAST_LENGTH) + intent { copy(snackbarVisible = false) } + } + } + } + + fun onCloseSnackbar() { + viewModelScope.launch { + intent { copy(snackbarVisible = false) } + } + } + + fun handleException(throwable: Throwable, retry: () -> Unit) { + onShowSnackbar( + SnackbarToken( + message = throwable.message ?: "알 수 없는 에러 입니다.", + actionButtonText = "재시도", + onClickActionButton = retry + ) + ) + } + + companion object { + private const val SHOW_TOAST_LENGTH = 2000L + } +} diff --git a/android/feature/navigator/src/main/res/drawable/folded_hands.xml b/android/feature/navigator/src/main/res/drawable/folded_hands.xml new file mode 100644 index 00000000..6f9f566e --- /dev/null +++ b/android/feature/navigator/src/main/res/drawable/folded_hands.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/android/feature/navigator/src/main/res/drawable/home.xml b/android/feature/navigator/src/main/res/drawable/home.xml new file mode 100644 index 00000000..7480e7b0 --- /dev/null +++ b/android/feature/navigator/src/main/res/drawable/home.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/android/feature/navigator/src/main/res/drawable/board.xml b/android/feature/navigator/src/main/res/drawable/ic_board.xml similarity index 100% rename from android/feature/navigator/src/main/res/drawable/board.xml rename to android/feature/navigator/src/main/res/drawable/ic_board.xml diff --git a/android/feature/navigator/src/main/res/values/strings.xml b/android/feature/navigator/src/main/res/values/strings.xml index 50fc3daa..ffa0a109 100644 --- a/android/feature/navigator/src/main/res/values/strings.xml +++ b/android/feature/navigator/src/main/res/values/strings.xml @@ -4,4 +4,6 @@ 게시판 학생관리 학급관리 + 함께달리기 + 이어달리기 diff --git a/android/feature/signin/build.gradle.kts b/android/feature/signin/build.gradle.kts index f285a3fd..8f8d3730 100644 --- a/android/feature/signin/build.gradle.kts +++ b/android/feature/signin/build.gradle.kts @@ -7,4 +7,6 @@ android { } dependencies { + implementation(libs.kakao.user) + } diff --git a/android/feature/signin/src/main/AndroidManifest.xml b/android/feature/signin/src/main/AndroidManifest.xml index a5918e68..cecf573e 100644 --- a/android/feature/signin/src/main/AndroidManifest.xml +++ b/android/feature/signin/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ - + + \ No newline at end of file diff --git a/android/feature/signin/src/main/java/com/sixkids/feature/signin/login/KakaoManager.kt b/android/feature/signin/src/main/java/com/sixkids/feature/signin/login/KakaoManager.kt new file mode 100644 index 00000000..a2e36643 --- /dev/null +++ b/android/feature/signin/src/main/java/com/sixkids/feature/signin/login/KakaoManager.kt @@ -0,0 +1,69 @@ +package com.sixkids.feature.signin.login + +import android.content.Context +import android.util.Log +import com.kakao.sdk.common.model.AuthError +import com.kakao.sdk.common.model.ClientError +import com.kakao.sdk.common.model.ClientErrorCause +import com.kakao.sdk.user.UserApiClient + +private const val TAG = "D107" + +object KakaoManager { + fun login( + context: Context, + onSuccess: (String) -> Unit, + onFailed: (Throwable?) -> Unit, + ) { + if (UserApiClient.instance.isKakaoTalkLoginAvailable(context)) { + loginWithKakaoTalk( + onSuccess = onSuccess, + onFailed = { error -> + if (error is AuthError || (error is ClientError && error.reason != ClientErrorCause.Cancelled)) { + loginWithKakaoAccount( + onSuccess = onSuccess, + onFailed = onFailed, + context = context, + ) + } + }, + context = context, + ) + } else { + loginWithKakaoAccount( + onSuccess = onSuccess, + onFailed = onFailed, + context = context, + ) + } + } + + private fun loginWithKakaoTalk( + context: Context, + onSuccess: (String) -> Unit, + onFailed: (Throwable?) -> Unit, + ) { + UserApiClient.instance.loginWithKakaoTalk(context) { token, error -> + Log.d(TAG, "loginWithKakaoTalk:$token") + if (token != null && token.idToken != null){ + onSuccess(token.idToken!!) + } else { + onFailed(error) + } + } + } + + private fun loginWithKakaoAccount( + context: Context, + onSuccess: (String) -> Unit, + onFailed: (Throwable?) -> Unit, + ) { + UserApiClient.instance.loginWithKakaoAccount(context) { token, error -> + if (token != null && token.idToken != null){ + onSuccess(token.idToken!!) + } else { + onFailed(error) + } + } + } +} \ No newline at end of file diff --git a/android/feature/signin/src/main/java/com/sixkids/feature/signin/login/LoginContract.kt b/android/feature/signin/src/main/java/com/sixkids/feature/signin/login/LoginContract.kt new file mode 100644 index 00000000..82cac746 --- /dev/null +++ b/android/feature/signin/src/main/java/com/sixkids/feature/signin/login/LoginContract.kt @@ -0,0 +1,17 @@ +package com.sixkids.feature.signin.login + +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState + +sealed interface LoginEffect : SideEffect{ + data object NavigateToHome : LoginEffect + data object NavigateToSignUp : LoginEffect + data object NavigateToTeacherOrganizationList : LoginEffect + data object NavigateToStudentOrganizationList : LoginEffect + data class OnShowSnackBar(val tkn : SnackbarToken) : LoginEffect +} + +data class LoginState( + val isLoading : Boolean = false, +) : UiState diff --git a/android/feature/signin/src/main/java/com/sixkids/feature/signin/login/LoginScreen.kt b/android/feature/signin/src/main/java/com/sixkids/feature/signin/login/LoginScreen.kt new file mode 100644 index 00000000..dfbe44ce --- /dev/null +++ b/android/feature/signin/src/main/java/com/sixkids/feature/signin/login/LoginScreen.kt @@ -0,0 +1,267 @@ +package com.sixkids.feature.signin.login + +import android.util.Log +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.slideInHorizontally +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +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.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.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Text +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +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 androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil.compose.AsyncImage +import com.sixkids.designsystem.component.screen.LoadingScreen +import com.sixkids.designsystem.theme.UlbanTheme +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.signin.R +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.extension.collectWithLifecycle +import kotlinx.coroutines.delay + +private const val TAG = "HONG" +@Composable +fun LoginRoute( + viewModel: LoginViewModel = hiltViewModel(), + navigateToHome: () -> Unit, + navigateToSignUp: () -> Unit, + navigateToTeacherOrganizationList: () -> Unit, + navigateToStudentOrganizationList: () -> Unit, + onShowSnackBar: (SnackbarToken) -> Unit +) { + val context = LocalContext.current + + val uiState = viewModel.uiState.collectAsStateWithLifecycle().value + + LaunchedEffect(key1 = Unit) { + viewModel.autoSignIn() + } + + viewModel.sideEffect.collectWithLifecycle { sideEffect -> + when (sideEffect) { + is LoginEffect.NavigateToSignUp -> navigateToSignUp() + LoginEffect.NavigateToHome -> navigateToHome() + is LoginEffect.OnShowSnackBar -> onShowSnackBar(sideEffect.tkn) + LoginEffect.NavigateToTeacherOrganizationList -> navigateToTeacherOrganizationList() + LoginEffect.NavigateToStudentOrganizationList -> navigateToStudentOrganizationList() + } + } + + LoginScreen( + uiState = uiState, + onLoginClick = { + KakaoManager.login( + context = context, + onSuccess = { idToken -> + viewModel.login(idToken) + }, + onFailed = { + Log.d(TAG, "카카오 에러!") + } + ) + } + + ) +} + +@Composable +fun LoginScreen( + uiState: LoginState = LoginState(), + onLoginClick: () -> Unit = {}, +) { + var animationStart by remember { + mutableStateOf(false) + } + LaunchedEffect(Unit) { + delay(300) + animationStart = true + } + + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.White) + ) { + Image( + painter = painterResource(id = R.drawable.main_background), + contentDescription = "Home Background", + alignment = Alignment.TopCenter, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .padding(0.dp, 20.dp, 0.dp, 0.dp) + ) { + AnimatedVisibility( + visible = animationStart, + enter = slideInHorizontally( + initialOffsetX = { -it }, + animationSpec = tween(1000) + ), + modifier = Modifier.weight(1f) + ) { + Column( + horizontalAlignment = Alignment.Start, + ) { + AsyncImage( + model = com.sixkids.designsystem.R.drawable.hifive, + contentDescription = "Rocket", + modifier = Modifier.fillMaxWidth(), + + ) + + AsyncImage( + model = com.sixkids.designsystem.R.drawable.paint, + contentDescription = "Paint", + modifier = Modifier + .padding(50.dp, 0.dp, 0.dp, 0.dp) + .rotate(15f) + ) + } + } + + AnimatedVisibility( + visible = animationStart, + enter = slideInHorizontally( + initialOffsetX = { it }, + animationSpec = tween(1000) + ), + modifier = Modifier + .weight(1f) + .align(Alignment.Top), + ) { + AsyncImage( + model = com.sixkids.designsystem.R.drawable.pencil, + contentDescription = "Pencil", + contentScale = ContentScale.FillHeight, + modifier = Modifier + .fillMaxWidth() + .height(280.dp) + .rotate(18f) + ) + } + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + ) { + Spacer(modifier = Modifier.weight(1f)) + + Text( + text = stringResource(id = R.string.signin_subtitle), + style = UlbanTypography.bodyMedium.copy( + fontSize = 16.sp + ), + modifier = Modifier + .align(Alignment.End) + .padding(0.dp, 100.dp, 20.dp, 0.dp) + ) + + Spacer(modifier = Modifier.height(5.dp)) + + Text( + text = stringResource(id = R.string.signin_title), + style = UlbanTypography.titleLarge.copy( + fontSize = 48.sp + ), + modifier = Modifier + .align(Alignment.End) + .padding(0.dp, 0.dp, 20.dp, 0.dp) + ) + + Spacer(modifier = Modifier.weight(1f)) + + LogInButton(onLoginClick) + } + + if (uiState.isLoading){ + LoadingScreen() + } + } +} + +@Composable +fun LogInButton( + onClick: () -> Unit, +){ + Button( + onClick = { onClick() }, + modifier = Modifier + .fillMaxWidth() + .padding(10.dp, 40.dp) + .height(50.dp), + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFFFEE500) + ) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Image( + painter = painterResource(id = R.drawable.kakao), + contentDescription = "Icon", + modifier = Modifier.size(24.dp) + ) + Spacer(Modifier.width(8.dp)) + Text( + text = stringResource(id = R.string.signin_kakao), + style = UlbanTypography.bodyMedium.copy(), + color = Color.Black, + textAlign = TextAlign.Center + ) + } + } +} + + +@Preview(showBackground = true) +@Composable +fun LoginScreenPreview() { + UlbanTheme { + LoginScreen() + } + +} diff --git a/android/feature/signin/src/main/java/com/sixkids/feature/signin/login/LoginViewModel.kt b/android/feature/signin/src/main/java/com/sixkids/feature/signin/login/LoginViewModel.kt new file mode 100644 index 00000000..47a450ec --- /dev/null +++ b/android/feature/signin/src/main/java/com/sixkids/feature/signin/login/LoginViewModel.kt @@ -0,0 +1,72 @@ +package com.sixkids.feature.signin.login + +import android.util.Log +import androidx.lifecycle.viewModelScope +import com.sixkids.domain.usecase.user.AutoSignInUseCase +import com.sixkids.domain.usecase.user.GetRoleUseCase +import com.sixkids.domain.usecase.user.SignInUseCase +import com.sixkids.model.NotFoundException +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +private const val TAG = "D107" +@HiltViewModel +class LoginViewModel @Inject constructor( + private val autoSignInUseCase: AutoSignInUseCase, + private val signInUseCase: SignInUseCase, + private val getRoleUseCase: GetRoleUseCase +) : BaseViewModel(LoginState()){ + + fun autoSignIn(){ + viewModelScope.launch { + autoSignInUseCase() + .onSuccess { + getRoleUseCase() + .onSuccess { + when(it){ + "TEACHER" -> postSideEffect(LoginEffect.NavigateToTeacherOrganizationList) + "STUDENT" -> { + Log.d(TAG, "autoSignIn: STUDENT") + postSideEffect(LoginEffect.NavigateToStudentOrganizationList)} + } + }.onFailure { + Log.d(TAG, "autoSignIn: ${it.message}") + } + }.onFailure { + Log.d(TAG, "autoSignIn: ${it.message}") + } + } + } + + fun login(idToken: String){ + viewModelScope.launch { + intent { copy(isLoading = true)} + signInUseCase(idToken) + .onSuccess { + getRoleUseCase() + .onSuccess { + when(it){ + "TEACHER" -> postSideEffect(LoginEffect.NavigateToTeacherOrganizationList) + "STUDENT" -> {postSideEffect(LoginEffect.NavigateToStudentOrganizationList)} + } + }.onFailure { + postSideEffect(LoginEffect.OnShowSnackBar(SnackbarToken("로그인에 실패했습니다"))) + } + }.onFailure { + when(it){ + is NotFoundException -> { + postSideEffect(LoginEffect.OnShowSnackBar(SnackbarToken("회원가입을 진행 해주세요"))) + postSideEffect(LoginEffect.NavigateToSignUp) + } + else -> { + postSideEffect(LoginEffect.OnShowSnackBar(SnackbarToken(it.message?:"로그인에 실패했습니다"))) + } + } + } + intent { copy(isLoading = false)} + } + } +} diff --git a/android/feature/signin/src/main/java/com/sixkids/feature/signin/navigation/SignInNavigation.kt b/android/feature/signin/src/main/java/com/sixkids/feature/signin/navigation/SignInNavigation.kt new file mode 100644 index 00000000..fa543d6b --- /dev/null +++ b/android/feature/signin/src/main/java/com/sixkids/feature/signin/navigation/SignInNavigation.kt @@ -0,0 +1,77 @@ +package com.sixkids.feature.signin.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavType +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import com.sixkids.feature.signin.login.LoginRoute +import com.sixkids.feature.signin.signup.SignUpPhotoRoute +import com.sixkids.feature.signin.signup.SignUpRoute +import com.sixkids.ui.SnackbarToken + + +fun NavController.navigateSignIn() { + navigate(SignInRoute.defaultRoute) +} + +fun NavController.navigateSignUp() { + navigate(SignInRoute.signUpRoute) +} + +fun NavController.navigateSignUpPhoto(isTeacher: Boolean) { + navigate(SignInRoute.signUpPhotoRoute(isTeacher)) +} + + +fun NavGraphBuilder.signInNavGraph( + navigateToSignUp: () -> Unit, + navigateSignUpPhoto: (Boolean) -> Unit, + navigateToHome: () -> Unit, + navigateToTeacherOrganizationList: () -> Unit, + navigateToStudentOrganizationList: () -> Unit, + onShowSnackBar: (SnackbarToken) -> Unit, + onBackClick : () -> Unit +) { + composable(route = SignInRoute.defaultRoute){ + LoginRoute( + navigateToSignUp = navigateToSignUp, + navigateToHome = navigateToHome, + onShowSnackBar = onShowSnackBar, + navigateToTeacherOrganizationList = navigateToTeacherOrganizationList, + navigateToStudentOrganizationList = navigateToStudentOrganizationList + ) + } + + composable(route = SignInRoute.signUpRoute){ + SignUpRoute( + navigateToSignUpPhoto = { isTeacher -> + navigateSignUpPhoto(isTeacher) + }, + onBackClick = onBackClick + ) + } + + composable( + route = SignInRoute.signUpPhotoRoute, + arguments = listOf(navArgument(SignInRoute.SIGN_UP_TEACHER) { type = NavType.BoolType }) + ){ + SignUpPhotoRoute( + onShowSnackBar = onShowSnackBar, + navigateToTeacherOrganizationList = navigateToTeacherOrganizationList, + navigateToStudentOrganizationList = navigateToStudentOrganizationList, + onBackClick = onBackClick + ) + } +} + +object SignInRoute{ + const val SIGN_UP_TEACHER = "isTeacher" + + const val defaultRoute = "signIn" + const val signUpRoute = "signUp" + const val signUpPhotoRoute = "sign-up-photo/{$SIGN_UP_TEACHER}" + + fun signUpPhotoRoute(isTeacher: Boolean) = "sign-up-photo/$isTeacher" + +} diff --git a/android/feature/signin/src/main/java/com/sixkids/feature/signin/signup/SignUpContract.kt b/android/feature/signin/src/main/java/com/sixkids/feature/signin/signup/SignUpContract.kt new file mode 100644 index 00000000..7b0722e2 --- /dev/null +++ b/android/feature/signin/src/main/java/com/sixkids/feature/signin/signup/SignUpContract.kt @@ -0,0 +1,38 @@ +package com.sixkids.feature.signin.signup + +import android.graphics.Bitmap +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState + +sealed interface SignUpRoleEffect : SideEffect { + data class NavigateToSignUpPhoto(val isTeacher: Boolean) : SignUpRoleEffect +} + +data class SignUpRoleState( + val role : Role = Role.TEACHER, +) : UiState + +enum class Role{ + TEACHER, + STUDENT +} + + +sealed interface SignUpPhotoEffect : SideEffect { + data object NavigateToTeacherOrganizationList : SignUpPhotoEffect + data object NavigateToStudentOrganizationList : SignUpPhotoEffect + data class OnShowSnackBar(val tkn : SnackbarToken) : SignUpPhotoEffect +} + +data class SignUpPhotoState( + val isLoading: Boolean = false, + val gender: Gender? = null, + val profileDefaultPhoto: Int? = null, + val profileUserPhoto: Bitmap? = null +) : UiState + +enum class Gender{ + MAN, + WOMAN +} \ No newline at end of file diff --git a/android/feature/signin/src/main/java/com/sixkids/feature/signin/signup/SignUpPhotoScreen.kt b/android/feature/signin/src/main/java/com/sixkids/feature/signin/signup/SignUpPhotoScreen.kt new file mode 100644 index 00000000..2cc2f451 --- /dev/null +++ b/android/feature/signin/src/main/java/com/sixkids/feature/signin/signup/SignUpPhotoScreen.kt @@ -0,0 +1,303 @@ +package com.sixkids.feature.signin.signup + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.ImageDecoder +import android.os.Build +import android.provider.MediaStore +import android.util.Log +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +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.aspectRatio +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.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +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 androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.sixkids.designsystem.R +import com.sixkids.designsystem.component.screen.LoadingScreen +import com.sixkids.designsystem.component.screen.UlbanTopSection +import com.sixkids.designsystem.theme.Blue +import com.sixkids.designsystem.theme.BlueDark +import com.sixkids.designsystem.theme.Cream +import com.sixkids.designsystem.theme.UlbanTheme +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.extension.collectWithLifecycle +import java.io.File +import java.io.FileOutputStream +import java.io.IOException + +private const val TAG = "D107" + +@Composable +fun SignUpPhotoRoute( + viewModel: SignUpPhotoViewModel = hiltViewModel(), + navigateToTeacherOrganizationList: () -> Unit, + navigateToStudentOrganizationList: () -> Unit, + onShowSnackBar: (SnackbarToken) -> Unit, + onBackClick: () -> Unit +) { + val context = LocalContext.current + val uiState = viewModel.uiState.collectAsStateWithLifecycle().value + + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickVisualMedia() + ) { uri -> + uri?.let { + try { + val bitmap = if (Build.VERSION.SDK_INT < 28) { + MediaStore.Images.Media.getBitmap(context.contentResolver, it) + } else { + ImageDecoder.decodeBitmap(ImageDecoder.createSource(context.contentResolver, it)) + } + viewModel.onProfilePhotoSelected(bitmap) + } catch (e: IOException) { + Log.e(TAG, "Error decoding bitmap", e) + } + } + } + + viewModel.sideEffect.collectWithLifecycle { + when (it) { + is SignUpPhotoEffect.OnShowSnackBar -> onShowSnackBar(it.tkn) + SignUpPhotoEffect.NavigateToTeacherOrganizationList -> navigateToTeacherOrganizationList() + SignUpPhotoEffect.NavigateToStudentOrganizationList -> navigateToStudentOrganizationList() + } + } + + SignUpPhotoScreen( + uiState = uiState, + isTeacher = viewModel.isTeacher, + onClickPhoto = { resId -> + when(resId){ + R.drawable.camera -> + launcher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) + R.drawable.teacher_man -> + viewModel.onProfileDefaultPhotoSelected(resId, Gender.MAN) + R.drawable.student_boy -> + viewModel.onProfileDefaultPhotoSelected(resId, Gender.MAN) + R.drawable.teacher_woman -> + viewModel.onProfileDefaultPhotoSelected(resId, Gender.WOMAN) + R.drawable.student_girl -> + viewModel.onProfileDefaultPhotoSelected(resId, Gender.WOMAN) + } + }, + onDoneClick = { + viewModel.signUp( + saveBitmapToFile(context, uiState.profileUserPhoto, "profile.jpg") + ) + }, + onBackClick = { + onBackClick() + } + ) +} +@Composable +fun SignUpPhotoScreen( + uiState: SignUpPhotoState = SignUpPhotoState(), + isTeacher: Boolean = true, + onClickPhoto: (Int) -> Unit = {}, + onDoneClick : () -> Unit = {}, + onBackClick : () -> Unit = {} +) { + val imageMan = if (isTeacher) R.drawable.teacher_man else R.drawable.student_boy + val imageWoman = if (isTeacher) R.drawable.teacher_woman else R.drawable.student_girl + Box(modifier = Modifier.fillMaxSize()){ + Column( + modifier = Modifier + .fillMaxSize() + .padding(21.dp) + ) { + UlbanTopSection(stringResource(id = com.sixkids.signin.R.string.signup_photo_title), onBackClick) + + Spacer(modifier = Modifier.height(60.dp)) + + SelectedPhotoCard( + uiState.profileDefaultPhoto ?: imageMan, + uiState.profileUserPhoto, + modifier = Modifier + .padding(10.dp) + .size(180.dp) + .align(Alignment.CenterHorizontally), + ) + + Spacer(modifier = Modifier.height(60.dp)) + + Row { + PhotoCard( + modifier = Modifier + .padding(10.dp) + .weight(1f) + .aspectRatio(1f), + img = imageMan, + onClickPhoto = onClickPhoto + ) + + PhotoCard( + modifier = Modifier + .padding(10.dp) + .weight(1f) + .aspectRatio(1f), + img = imageWoman, + onClickPhoto = onClickPhoto + ) + + PhotoCard( + modifier = Modifier + .padding(10.dp) + .weight(1f) + .aspectRatio(1f), + img = R.drawable.camera, + onClickPhoto = onClickPhoto + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + DoneButton(onDoneClick) + } + if (uiState.isLoading){ + LoadingScreen() + } + } +} + +@Composable +fun DoneButton( + onDoneClick: () -> Unit +) { + Button( + onClick = { onDoneClick() }, + modifier = Modifier + .fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Blue + ) + ) + { + Text( + text = "완료", + style = UlbanTypography.titleSmall.copy( + fontSize = 14.sp, + color = BlueDark + ), + modifier = Modifier.padding(5.dp) + ) + } +} + +@Composable +fun SelectedPhotoCard(defaultImage: Int, bitmap: Bitmap?, modifier: Modifier = Modifier) { + Card( + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp, pressedElevation = 8.dp), + colors = CardDefaults.cardColors( + containerColor = Cream + ), + modifier = modifier, + ) { + Column( + modifier = Modifier.fillMaxSize(), + ) { + if (bitmap != null) { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = "selected photo", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } else { + Image( + painter = painterResource( + id = defaultImage + ), + contentDescription = "selected photo", + modifier = Modifier.fillMaxSize(), + ) + } + + } + } +} + +@Composable +fun PhotoCard(modifier: Modifier = Modifier, img: Int, onClickPhoto: (Int) -> Unit) { + Card( + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp, pressedElevation = 8.dp), + colors = CardDefaults.cardColors( + containerColor = Cream + ), + modifier = modifier.clickable { + onClickPhoto(img) + }, + ) { + Column( + modifier = Modifier.fillMaxSize(), + ) { + Image( + painter = painterResource(id = img), + contentDescription = "profile", + modifier = Modifier, + ) + } + } +} + +fun saveBitmapToFile(context: Context, bitmap: Bitmap?, fileName: String): File? { + val directory = context.getExternalFilesDir(null) ?: return null + + val file = File(directory, fileName) + var fileOutputStream: FileOutputStream? = null + + try { + fileOutputStream = FileOutputStream(file) + bitmap?.compress(Bitmap.CompressFormat.JPEG, 100, fileOutputStream) + fileOutputStream.flush() + } catch (e: Exception) { + e.printStackTrace() + return null + } finally { + try { + fileOutputStream?.close() + } catch (e: Exception) { + e.printStackTrace() + } + } + + return file +} + +@Composable +@Preview(showBackground = true) +fun SignUpPhotoScreenPreview() { + UlbanTheme { + SignUpPhotoScreen() + } +} \ No newline at end of file diff --git a/android/feature/signin/src/main/java/com/sixkids/feature/signin/signup/SignUpPhotoViewModel.kt b/android/feature/signin/src/main/java/com/sixkids/feature/signin/signup/SignUpPhotoViewModel.kt new file mode 100644 index 00000000..1e538d86 --- /dev/null +++ b/android/feature/signin/src/main/java/com/sixkids/feature/signin/signup/SignUpPhotoViewModel.kt @@ -0,0 +1,86 @@ +package com.sixkids.feature.signin.signup + +import android.graphics.Bitmap +import androidx.annotation.DrawableRes +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.sixkids.domain.usecase.user.GetRoleUseCase +import com.sixkids.domain.usecase.user.SignUpUseCase +import com.sixkids.feature.signin.navigation.SignInRoute.SIGN_UP_TEACHER +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import java.io.File +import javax.inject.Inject + + +@HiltViewModel +class SignUpPhotoViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val signUpUseCase: SignUpUseCase, + private val getRoleUseCase: GetRoleUseCase +) : BaseViewModel(SignUpPhotoState()) { + + val isTeacher = savedStateHandle.get(SIGN_UP_TEACHER)!! + + fun onProfilePhotoSelected(bitmap: Bitmap) { + intent { + copy( + profileUserPhoto = bitmap, + profileDefaultPhoto = null, + gender = null + ) + } + } + + fun onProfileDefaultPhotoSelected(@DrawableRes photo: Int, gender: Gender) { + intent { + copy( + profileDefaultPhoto = photo, + profileUserPhoto = null, + gender = gender + ) + } + } + + fun signUp(file: File?) { + viewModelScope.launch { + intent { copy(isLoading = true)} + val defaultImage = when (file) { + null -> { + when (uiState.value.gender) { + null -> 0 + Gender.MAN -> if (isTeacher) 1 else 3 + Gender.WOMAN -> if (isTeacher) 2 else 4 + } + } + else -> 0 + } + + signUpUseCase( + file = file, + defaultImage = defaultImage, + role = if (isTeacher) "TEACHER" else "STUDENT" + ).onSuccess { + postSideEffect(SignUpPhotoEffect.OnShowSnackBar(SnackbarToken( + message = "환영합니다." + ))) + getRoleUseCase() + .onSuccess { + when(it){ + "TEACHER" -> postSideEffect(SignUpPhotoEffect.NavigateToTeacherOrganizationList) + "STUDENT" -> postSideEffect(SignUpPhotoEffect.NavigateToStudentOrganizationList) + } + }.onFailure { + + } + }.onFailure { + postSideEffect(SignUpPhotoEffect.OnShowSnackBar(SnackbarToken( + message = it.message ?: "알 수 없는 에러 입니다." + ))) + } + intent { copy(isLoading = false)} + } + } +} diff --git a/android/feature/signin/src/main/java/com/sixkids/feature/signin/signup/SignUpRoleScreen.kt b/android/feature/signin/src/main/java/com/sixkids/feature/signin/signup/SignUpRoleScreen.kt new file mode 100644 index 00000000..c5ac5b86 --- /dev/null +++ b/android/feature/signin/src/main/java/com/sixkids/feature/signin/signup/SignUpRoleScreen.kt @@ -0,0 +1,176 @@ +package com.sixkids.feature.signin.signup + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +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.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +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 androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.sixkids.designsystem.R +import com.sixkids.designsystem.component.screen.UlbanTopSection +import com.sixkids.designsystem.theme.Green +import com.sixkids.designsystem.theme.Red +import com.sixkids.designsystem.theme.UlbanTheme +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.ui.extension.collectWithLifecycle + +@Composable +fun SignUpRoute( + viewModel: SignUpRoleViewModel = hiltViewModel(), + navigateToSignUpPhoto: (Boolean) -> Unit, + onBackClick: () -> Unit +) { + viewModel.sideEffect.collectWithLifecycle { sideEffect -> + when (sideEffect) { + is SignUpRoleEffect.NavigateToSignUpPhoto -> navigateToSignUpPhoto(sideEffect.isTeacher) + } + } + + SignUpScreen( + onTeacherClick = { + viewModel.onTeacherClick(it) + }, + onBackClick = { + onBackClick() + } + ) +} + +@Composable +fun SignUpScreen( + onTeacherClick: (Boolean) -> Unit = {}, + onBackClick : () -> Unit = {} +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(21.dp) + ) { + UlbanTopSection(stringResource(id = com.sixkids.signin.R.string.signup_role_title), onBackClick = onBackClick) + BodySection( + onCardClick = onTeacherClick, + modifier = Modifier + .weight(1f) + .padding(0.dp, 0.dp, 0.dp, 40.dp) + ) + } +} + +@Composable +fun BodySection(onCardClick: (Boolean) -> Unit, modifier: Modifier = Modifier) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Card( + modifier = Modifier + .padding(20.dp) + .fillMaxWidth() + .clickable { + onCardClick(true) + }, + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors( + containerColor = Red + ), + elevation = CardDefaults.cardElevation( + defaultElevation = 4.dp, + pressedElevation = 8.dp + ), + ) { + Row( + modifier = Modifier + .padding(40.dp, 10.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + + Image( + painter = painterResource(id = R.drawable.teacher_woman), + contentDescription = "teacher", + modifier = Modifier.size(120.dp) + ) + + Spacer(modifier = Modifier.size(10.dp)) + + Text( + text = "선생님", + style = UlbanTypography.titleLarge.copy( + fontSize = 28.sp + ), + ) + + } + } + + Card( + modifier = Modifier + .padding(20.dp) + .fillMaxWidth() + .clickable { + onCardClick(false) + }, + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors( + containerColor = Green + ), + elevation = CardDefaults.cardElevation( + defaultElevation = 4.dp, + pressedElevation = 8.dp + ), + ) { + Row( + modifier = Modifier + .padding(40.dp, 10.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End + ) { + Text( + text = "학생", + style = UlbanTypography.titleLarge.copy( + fontSize = 28.sp + ), + ) + + Spacer(modifier = Modifier.size(10.dp)) + + Image( + painter = painterResource(id = R.drawable.student_girl), + contentDescription = "student", + modifier = Modifier.size(120.dp) + ) + + } + } + } +} + +@Composable +@Preview(showBackground = true) +fun SignUpScreenPreview() { + UlbanTheme { + SignUpScreen() + } +} \ No newline at end of file diff --git a/android/feature/signin/src/main/java/com/sixkids/feature/signin/signup/SignUpRoleViewModel.kt b/android/feature/signin/src/main/java/com/sixkids/feature/signin/signup/SignUpRoleViewModel.kt new file mode 100644 index 00000000..079fbc4f --- /dev/null +++ b/android/feature/signin/src/main/java/com/sixkids/feature/signin/signup/SignUpRoleViewModel.kt @@ -0,0 +1,16 @@ +package com.sixkids.feature.signin.signup + +import android.graphics.Bitmap +import com.sixkids.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class SignUpRoleViewModel @Inject constructor( + +) : BaseViewModel(SignUpRoleState()){ + + fun onTeacherClick(isTeacher: Boolean){ + postSideEffect(SignUpRoleEffect.NavigateToSignUpPhoto(isTeacher)) + } +} \ No newline at end of file diff --git a/android/feature/signin/src/main/res/drawable/kakao.xml b/android/feature/signin/src/main/res/drawable/kakao.xml new file mode 100644 index 00000000..a768edff --- /dev/null +++ b/android/feature/signin/src/main/res/drawable/kakao.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/feature/signin/src/main/res/drawable/main_background.png b/android/feature/signin/src/main/res/drawable/main_background.png new file mode 100644 index 00000000..d0be43bc Binary files /dev/null and b/android/feature/signin/src/main/res/drawable/main_background.png differ diff --git a/android/feature/signin/src/main/res/values/strings.xml b/android/feature/signin/src/main/res/values/strings.xml new file mode 100644 index 00000000..75c00df8 --- /dev/null +++ b/android/feature/signin/src/main/res/values/strings.xml @@ -0,0 +1,13 @@ + + + + 울반 + 슬기로운 학급생활 메이트 + 5초 만에 카카오로 시작하기 + + + 어떤 회원으로 가입할까요? + + + 프로필 사진을 선택하세요 + \ No newline at end of file diff --git a/android/feature/student/board/build.gradle.kts b/android/feature/student/board/build.gradle.kts index 72643535..c1033ab3 100644 --- a/android/feature/student/board/build.gradle.kts +++ b/android/feature/student/board/build.gradle.kts @@ -7,4 +7,5 @@ android { } dependencies { + implementation(libs.bundles.paging) } diff --git a/android/feature/student/board/src/main/java/com/sixkids/student/board/component/CommentCount.kt b/android/feature/student/board/src/main/java/com/sixkids/student/board/component/CommentCount.kt new file mode 100644 index 00000000..46de2b50 --- /dev/null +++ b/android/feature/student/board/src/main/java/com/sixkids/student/board/component/CommentCount.kt @@ -0,0 +1,37 @@ +package com.sixkids.student.board.component + +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import com.sixkids.designsystem.theme.BlueDark +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.designsystem.R as UlbanRes + +@Composable +fun CommentCount( + count: Int +) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = ImageVector.vectorResource(id = UlbanRes.drawable.ic_chat_bubble), + contentDescription = null, + tint = BlueDark + ) + Text( + text = count.toString(), + style = UlbanTypography.bodyMedium + ) + } +} + + +@Preview(showBackground = true) +@Composable +fun CommentCountPreview() { + CommentCount(10) +} \ No newline at end of file diff --git a/android/feature/student/board/src/main/java/com/sixkids/student/board/component/CommentItem.kt b/android/feature/student/board/src/main/java/com/sixkids/student/board/component/CommentItem.kt new file mode 100644 index 00000000..f3795fbc --- /dev/null +++ b/android/feature/student/board/src/main/java/com/sixkids/student/board/component/CommentItem.kt @@ -0,0 +1,133 @@ +package com.sixkids.student.board.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Card +import androidx.compose.material3.CardColors +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.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.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.sixkids.designsystem.theme.Blue +import com.sixkids.designsystem.theme.Gray +import com.sixkids.designsystem.theme.GrayLight +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.designsystem.R as UlbanRes + +@Composable +fun CommentItem( + modifier: Modifier = Modifier, + selected: Boolean = false, + writer: String = "", + dateString: String = "00/00 00:00", + writerImageUrl: String = "", + commentString: String = "", + isRecomment: Boolean = false, + recommentOnclick: () -> Unit = {}, + deleteOnclick: (() -> Unit)? = null +){ + Card( + colors = CardDefaults.cardColors( + containerColor = + if (selected) { Blue} + else if (isRecomment) {GrayLight} + else {Color.Transparent} + ), + ) { + Column( + modifier = modifier + .padding(start = 10.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + AsyncImage( + modifier = Modifier + .height(36.dp) + .aspectRatio(1f), + model = writerImageUrl, + contentScale = ContentScale.Crop, + contentDescription = "작성자 프로필 사진" + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = writer, + style = UlbanTypography.bodyMedium + ) + Spacer(modifier = Modifier.weight(1f)) + if (!isRecomment) { + Icon( + modifier = Modifier.clickable{recommentOnclick()}, + imageVector = ImageVector.vectorResource(id = UlbanRes.drawable.ic_chat_bubble_outline), + contentDescription = null + ) + } + if (deleteOnclick != null) { + Icon( + modifier = Modifier.clickable{deleteOnclick()}, + imageVector = ImageVector.vectorResource(id = UlbanRes.drawable.ic_delete), + contentDescription = null + ) + } + } + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = commentString, + style = UlbanTypography.bodyMedium + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = dateString, + style = UlbanTypography.bodySmall.copy( + color = Gray + ) + ) + Spacer(modifier = Modifier.height(4.dp)) + } + } +} + +@Preview(showBackground = true) +@Composable +fun CommentItemPreview() { + Column { + CommentItem( + writer = "오하빈", + dateString = "09/01 12:00", + writerImageUrl = "https://www.google.com/images/branding/googlelogo/2x/googlelogo_light_color_272x92dp.png", + commentString = "댓글 내용", + deleteOnclick = {}, + selected = true + ) + CommentItem( + writer = "오하빈", + dateString = "09/01 12:00", + commentString = "댓글 내용", + writerImageUrl = "https://www.google.com/images/branding/googlelogo/2x/googlelogo_light_color_272x92dp.png", + ) + CommentItem( + writer = "오하빈", + dateString = "09/01 12:00", + commentString = "댓글 내용", + writerImageUrl = "https://www.google.com/images/branding/googlelogo/2x/googlelogo_light_color_272x92dp.png", + isRecomment = true, + deleteOnclick = {} + ) + } + +} \ No newline at end of file diff --git a/android/feature/student/board/src/main/java/com/sixkids/student/board/component/CommentTextField.kt b/android/feature/student/board/src/main/java/com/sixkids/student/board/component/CommentTextField.kt new file mode 100644 index 00000000..a318fd14 --- /dev/null +++ b/android/feature/student/board/src/main/java/com/sixkids/student/board/component/CommentTextField.kt @@ -0,0 +1,77 @@ +package com.sixkids.student.board.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.Send +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.sixkids.designsystem.component.textfield.UlbanBasicTextField +import com.sixkids.designsystem.theme.GrayLight +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.student.board.R + + +@Composable +fun CommentTextField( + msg: String = "", + onTextIuputChange: (String) -> Unit = {}, + onSendClick: (String) -> Unit = {}, +) { + Card( + modifier = Modifier + .padding(6.dp), + colors = CardDefaults.cardColors( + containerColor = GrayLight + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + UlbanBasicTextField( + text = msg, + onTextChange = onTextIuputChange, + modifier = Modifier + .padding(10.dp, 0.dp) + .weight(1f) + .wrapContentHeight(), + maxLines = 3, + textStyle = UlbanTypography.bodyMedium.copy(fontWeight = FontWeight.Normal), + hint = stringResource(id = R.string.student_board_detail_comment_hint) + ) + + Icon( + Icons.AutoMirrored.Outlined.Send, + contentDescription = "", + modifier = Modifier + .size(30.dp) + .padding(end = 4.dp) + .clickable { + onSendClick(msg) + } + ) + + } + } + +} + +@Preview(showBackground = true) +@Composable +fun CommentTextFieldPreview() { + CommentTextField() +} \ No newline at end of file diff --git a/android/feature/student/board/src/main/java/com/sixkids/student/board/component/PageTitle.kt b/android/feature/student/board/src/main/java/com/sixkids/student/board/component/PageTitle.kt new file mode 100644 index 00000000..28da552b --- /dev/null +++ b/android/feature/student/board/src/main/java/com/sixkids/student/board/component/PageTitle.kt @@ -0,0 +1,55 @@ +package com.sixkids.student.board.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.designsystem.R as UlbanRes + +@Composable +fun PageTitle( + modifier: Modifier = Modifier, + title: String, + cancelOnclick: () -> Unit = {}, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + modifier = Modifier + .clickable { cancelOnclick() }, + imageVector = ImageVector.vectorResource(id = UlbanRes.drawable.ic_cancel_post), + contentDescription = null + ) + Spacer(modifier = Modifier.width(14.dp)) + Text( + text = title, + style = UlbanTypography.titleLarge.copy( + fontWeight = FontWeight.Medium, + ), + ) + } +} + + +@Preview(showBackground = true) +@Composable +fun PageTitlePreview() { + PageTitle( + title = "글 쓰기", + cancelOnclick = {} + ) +} \ No newline at end of file diff --git a/android/feature/student/board/src/main/java/com/sixkids/student/board/component/PostItem.kt b/android/feature/student/board/src/main/java/com/sixkids/student/board/component/PostItem.kt new file mode 100644 index 00000000..cc833ca9 --- /dev/null +++ b/android/feature/student/board/src/main/java/com/sixkids/student/board/component/PostItem.kt @@ -0,0 +1,80 @@ +package com.sixkids.student.board.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.layout.width +import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.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 com.sixkids.designsystem.theme.UlbanTypography + +@Composable +fun PostItem( + modifier: Modifier = Modifier , + title: String, + writer: String, + commentCount: Int, + dateString: String, + dividerColor: Color = Color.Black, + onClick: () -> Unit = {} +) { + Column( + modifier = modifier.clickable { onClick() } + ) { + Text( + text = title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = UlbanTypography.titleMedium + ) + Spacer(modifier = Modifier.height(10.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + if (commentCount > 0){ + CommentCount(count = commentCount) + } + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = writer, + style = UlbanTypography.bodyMedium + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + text = dateString, + style = UlbanTypography.bodyMedium + ) + } + HorizontalDivider( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + thickness = 2.dp, + color = dividerColor + ) + } +} + +@Preview(showBackground = true) +@Composable +fun PostItemPreview() { + PostItem( + title = "이따 마크 할 사람~~!", + writer = "오하빈", + commentCount = 3, + dateString = "2024.04.16 14:30" + ) +} \ No newline at end of file diff --git a/android/feature/student/board/src/main/java/com/sixkids/student/board/component/PostWriterInfo.kt b/android/feature/student/board/src/main/java/com/sixkids/student/board/component/PostWriterInfo.kt new file mode 100644 index 00000000..d5218bd8 --- /dev/null +++ b/android/feature/student/board/src/main/java/com/sixkids/student/board/component/PostWriterInfo.kt @@ -0,0 +1,64 @@ +package com.sixkids.student.board.component + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.sixkids.designsystem.theme.UlbanTypography + +@Composable +fun PostWriterInfo( + height: Dp = 60.dp, + writer: String = "", + dateString: String = "00/00 00:00", + writerImageUrl: String = "" +) { + Row( + modifier = Modifier.height(height), + verticalAlignment = Alignment.CenterVertically + ) { + AsyncImage( + modifier = Modifier + .fillMaxHeight() + .aspectRatio(1f), + model = writerImageUrl, + contentScale = ContentScale.Crop, + contentDescription = "프로필 사진" + ) + Spacer(modifier = Modifier.width(8.dp)) + Column { + Text( + text = writer, + style = UlbanTypography.bodyLarge + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = dateString, + style = UlbanTypography.bodyMedium + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun PostWriterInfoPreview() { + PostWriterInfo( + height = 60.dp, + writer = "홍유준 선생님", + dateString = "10/10 10:10", + writerImageUrl = "" + ) +} \ No newline at end of file diff --git a/android/feature/student/board/src/main/java/com/sixkids/student/board/detail/StudentBoardDetailContract.kt b/android/feature/student/board/src/main/java/com/sixkids/student/board/detail/StudentBoardDetailContract.kt new file mode 100644 index 00000000..80722b92 --- /dev/null +++ b/android/feature/student/board/src/main/java/com/sixkids/student/board/detail/StudentBoardDetailContract.kt @@ -0,0 +1,17 @@ +package com.sixkids.student.board.detail + +import com.sixkids.model.PostDetail +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState + +sealed interface StudentBoardDetailEffect: SideEffect { + data object RefreshPostDetail: StudentBoardDetailEffect + data class OnShowSnackbar(val message: String) : StudentBoardDetailEffect +} + +data class StudentBoardDetailState( + val isLoading: Boolean = false, + val postDetail: PostDetail = PostDetail(), + val commentText: String = "", + val selectedCommentId: Long? = null, +) : UiState \ No newline at end of file diff --git a/android/feature/student/board/src/main/java/com/sixkids/student/board/detail/StudentBoardDetailScreen.kt b/android/feature/student/board/src/main/java/com/sixkids/student/board/detail/StudentBoardDetailScreen.kt new file mode 100644 index 00000000..f21668ed --- /dev/null +++ b/android/feature/student/board/src/main/java/com/sixkids/student/board/detail/StudentBoardDetailScreen.kt @@ -0,0 +1,241 @@ +package com.sixkids.student.board.detail + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import coil.compose.AsyncImage +import com.sixkids.designsystem.R +import com.sixkids.designsystem.component.screen.LoadingScreen +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.model.Comment +import com.sixkids.model.MemberSimple +import com.sixkids.model.PostDetail +import com.sixkids.model.Recomment +import com.sixkids.student.board.component.CommentItem +import com.sixkids.student.board.component.CommentTextField +import com.sixkids.student.board.component.PostWriterInfo +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.util.formatToMonthDayTime +import java.time.LocalDateTime + +@Composable +fun StudentBoardDetailRoute( + viewModel: StudentBoardDetailViewModel = hiltViewModel(), + padding: PaddingValues, + onShowSnackBar: (SnackbarToken) -> Unit +) { + val uiState by viewModel.uiState.collectAsState() + + LaunchedEffect(Unit) { + viewModel.getPostDetail() + } + + LaunchedEffect(viewModel.sideEffect) { + viewModel.sideEffect.collect { sideEffect -> + when (sideEffect) { + StudentBoardDetailEffect.RefreshPostDetail -> viewModel.getPostDetail() + is StudentBoardDetailEffect.OnShowSnackbar -> { + onShowSnackBar(SnackbarToken(message = sideEffect.message)) + } + } + } + } + + Box(modifier = Modifier.padding(padding)) { + StudentBoardDetailScreen( + studentBoardDetailState = uiState, + onCommentTextChanged = viewModel::onCommentTextChanged, + onClickComment = viewModel::onSelectedCommentId, + onClickSubmitComment = viewModel::onNewComment, + ) + } +} + +@Composable +fun StudentBoardDetailScreen( + modifier: Modifier = Modifier, + studentBoardDetailState: StudentBoardDetailState = StudentBoardDetailState(), + onCommentTextChanged: (String) -> Unit = {}, + onClickComment: (Long) -> Unit = {}, + onClickSubmitComment: () -> Unit = {}, + postDeleteOnclick: () -> Unit = {} +) { + + val scrollState = rememberScrollState() + + BackHandler( + enabled = studentBoardDetailState.selectedCommentId != null, + onBack = {onClickComment(studentBoardDetailState.selectedCommentId?: 0)} + ) + + Box{ + Column { + Column( + modifier = modifier + .weight(1f) + .padding(20.dp) + .verticalScroll(scrollState), + ) { + // 작성자 정보 + Row( + verticalAlignment = Alignment.CenterVertically + ) { + PostWriterInfo( + height = 60.dp, + writer = studentBoardDetailState.postDetail.writeMember.name, + dateString = studentBoardDetailState.postDetail.createTime.formatToMonthDayTime(), + writerImageUrl = studentBoardDetailState.postDetail.writeMember.photo + ) + Spacer(modifier = Modifier.weight(1f)) + Icon( + modifier = Modifier + .size(30.dp) + .clickable { postDeleteOnclick() }, + imageVector = ImageVector.vectorResource(id = R.drawable.ic_delete), + contentDescription = "더보기" + ) + } + + Spacer(modifier = Modifier.height(10.dp)) + Text( + text = studentBoardDetailState.postDetail.title, + style = UlbanTypography.titleLarge + ) + Spacer(modifier = Modifier.height(10.dp)) + // 이미지 + if (studentBoardDetailState.postDetail.imageUri.isNotEmpty()) { + AsyncImage( + model = studentBoardDetailState.postDetail.imageUri, + contentDescription = null, + contentScale = ContentScale.Crop, + ) + } + Spacer(modifier = Modifier.height(10.dp)) + Text( + text = studentBoardDetailState.postDetail.content, + style = UlbanTypography.bodyLarge + ) + Spacer(modifier = Modifier.height(10.dp)) + HorizontalDivider( + thickness = 2.dp, + color = Color.Black + ) + // 댓글 목록 + for (comment in studentBoardDetailState.postDetail.comments) { + CommentItem( + selected = studentBoardDetailState.selectedCommentId == comment.id, + writer = comment.member.name, + dateString = comment.createTime.formatToMonthDayTime(), + writerImageUrl = comment.member.photo, + commentString = comment.content, + recommentOnclick = { + onClickComment(comment.id) + } + ) + // 대댓글 목록 + for (recomment in comment.recomments) { + Row { + Icon( + modifier = Modifier.padding(4.dp), + imageVector = ImageVector.vectorResource(id = R.drawable.ic_recomment), + contentDescription = null + ) + CommentItem( + writer = recomment.member.name, + dateString = recomment.createTime.formatToMonthDayTime(), + writerImageUrl = recomment.member.photo, + commentString = recomment.content, + isRecomment = true + ) + } + + } + } + } + CommentTextField( + msg = studentBoardDetailState.commentText, + onTextIuputChange = onCommentTextChanged, + onSendClick = { onClickSubmitComment() } + ) + } + + if (studentBoardDetailState.isLoading) { + LoadingScreen() + } + } +} + +@Preview(showBackground = true) +@Composable +fun StudentBoardDetailScreenPreview() { + StudentBoardDetailScreen( + studentBoardDetailState = postDetailStateDummy + ) +} + +val recommentDummy = Recomment( + 1, + member = MemberSimple( + id = 1, + name = "작성자", + photo = "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTKe3bkhl96AgtmHyTiKW-KXRst2-5MoY6xB9-mZP74BQ&s" + ), + content = "내용내용내용내용내용내용내용내용내용내용내용내용내용내용내용", + createTime = LocalDateTime.now(), + updateTime = LocalDateTime.now(), + 1, +) + +val commentDummy = Comment( + 1, + member = MemberSimple( + id = 1, + name = "작성자", + photo = "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTKe3bkhl96AgtmHyTiKW-KXRst2-5MoY6xB9-mZP74BQ&s" + ), + "내용내용내용 내용내용내용내용내용내용내용내용내용 내용내용내용내용내용내용내용내용내용", + LocalDateTime.now(), + LocalDateTime.now(), + listOf(recommentDummy, recommentDummy) +) + +val postDetailStateDummy = StudentBoardDetailState( + isLoading = false, + postDetail = PostDetail( + title = "제목", + content = "내용내용내용내용내용내용내용내용내용내용내용내용내용", + writeMember = MemberSimple( + id = 1, + name = "작성자", + photo = "https://picsum.photos/200/300" + ), + createTime = LocalDateTime.now(), + imageUri = "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTKe3bkhl96AgtmHyTiKW-KXRst2-5MoY6xB9-mZP74BQ&s", + comments = listOf(commentDummy, commentDummy) + ) +) \ No newline at end of file diff --git a/android/feature/student/board/src/main/java/com/sixkids/student/board/detail/StudentBoardDetailViewModel.kt b/android/feature/student/board/src/main/java/com/sixkids/student/board/detail/StudentBoardDetailViewModel.kt new file mode 100644 index 00000000..2e654aaa --- /dev/null +++ b/android/feature/student/board/src/main/java/com/sixkids/student/board/detail/StudentBoardDetailViewModel.kt @@ -0,0 +1,102 @@ +package com.sixkids.student.board.detail + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.sixkids.domain.usecase.comment.DeleteCommentUseCase +import com.sixkids.domain.usecase.comment.NewCommentUseCase +import com.sixkids.domain.usecase.comment.NewRecommentUseCase +import com.sixkids.domain.usecase.comment.ReportCommentUseCase +import com.sixkids.domain.usecase.comment.UpdateCommentUsecase +import com.sixkids.domain.usecase.post.DeletePostUseCase +import com.sixkids.domain.usecase.post.GetPostDetailUseCase +import com.sixkids.domain.usecase.post.UpdatePostUseCase +import com.sixkids.student.board.navigation.StudentBoardRoute +import com.sixkids.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class StudentBoardDetailViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val getPostDetailUseCase: GetPostDetailUseCase, + private val updatePostUseCase: UpdatePostUseCase, + private val deletePostUsecase: DeletePostUseCase, + private val deleteCommentUseCase: DeleteCommentUseCase, + private val updateCommentUsecase: UpdateCommentUsecase, + private val newCommentUseCase: NewCommentUseCase, + private val newRecommentUseCase: NewRecommentUseCase, + private val reportCommentUseCase: ReportCommentUseCase +): BaseViewModel(StudentBoardDetailState()){ + + private val postId: Long = savedStateHandle.get(StudentBoardRoute.postDetailARG)!! + + fun onCommentTextChanged(commentText: String) = intent { copy(commentText = commentText) } + fun onSelectedCommentId(commentId: Long?) = intent { + if (currentState.selectedCommentId == commentId) { + copy(selectedCommentId = null) + } else { + copy(selectedCommentId = commentId) + } + } + + fun getPostDetail() { + viewModelScope.launch { + intent { copy(isLoading = true) } + getPostDetailUseCase(postId).onSuccess { + intent { copy(postDetail = it) } + }.onFailure { + postSideEffect(StudentBoardDetailEffect.OnShowSnackbar(it.message ?: "게시글을 불러오지 못했어요")) + } + intent { copy(isLoading = false) } + } + } + + fun onNewComment() { + if (currentState.commentText.isBlank()) { + postSideEffect(StudentBoardDetailEffect.OnShowSnackbar("댓글을 입력해주세요")) + } else { + if (currentState.selectedCommentId == null) { + viewModelScope.launch { + intent { copy(isLoading = true) } + newCommentUseCase( + postId = postId, + content = currentState.commentText, + ).onSuccess { + postSideEffect(StudentBoardDetailEffect.OnShowSnackbar("댓글이 작성되었습니다")) + intent { copy(commentText = "", selectedCommentId = null) } + getPostDetail() + }.onFailure { + postSideEffect( + StudentBoardDetailEffect.OnShowSnackbar( + it.message ?: "댓글 작성에 실패했어요" + ) + ) + } + intent { copy(isLoading = false) } + } + } else { + viewModelScope.launch { + intent { copy(isLoading = true) } + newRecommentUseCase( + postId = postId, + content = currentState.commentText, + currentState.selectedCommentId!! + ).onSuccess { + postSideEffect(StudentBoardDetailEffect.OnShowSnackbar("댓글이 작성되었습니다")) + intent { copy(commentText = "", selectedCommentId = null)} + getPostDetail() + }.onFailure { + postSideEffect( + StudentBoardDetailEffect.OnShowSnackbar( + it.message ?: "댓글 작성에 실패했어요" + ) + ) + } + intent { copy(isLoading = false) } + } + } + } + } + +} \ No newline at end of file diff --git a/android/feature/student/board/src/main/java/com/sixkids/student/board/main/StudentBoardMainContract.kt b/android/feature/student/board/src/main/java/com/sixkids/student/board/main/StudentBoardMainContract.kt new file mode 100644 index 00000000..4c6b1fb5 --- /dev/null +++ b/android/feature/student/board/src/main/java/com/sixkids/student/board/main/StudentBoardMainContract.kt @@ -0,0 +1,15 @@ +package com.sixkids.student.board.main + +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState + +sealed interface StudentBoardMainEffect : SideEffect { + data object NavigateToPostDetail: StudentBoardMainEffect + data object NavigateToWritePost: StudentBoardMainEffect + data class OnShowSnackBar(val message : String) : StudentBoardMainEffect +} + +data class StudentBoardMainState( + val isLoding: Boolean = false, + val classString: String = "", +): UiState \ No newline at end of file diff --git a/android/feature/student/board/src/main/java/com/sixkids/student/board/main/StudentBoardMainScreen.kt b/android/feature/student/board/src/main/java/com/sixkids/student/board/main/StudentBoardMainScreen.kt new file mode 100644 index 00000000..be7e9d24 --- /dev/null +++ b/android/feature/student/board/src/main/java/com/sixkids/student/board/main/StudentBoardMainScreen.kt @@ -0,0 +1,159 @@ +package com.sixkids.student.board.main + +import android.util.Log +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import com.sixkids.designsystem.component.appbar.UlbanDefaultAppBar +import com.sixkids.designsystem.R as UlbanRes +import com.sixkids.designsystem.component.appbar.UlbanDetailAppBar +import com.sixkids.designsystem.component.button.EditFAB +import com.sixkids.designsystem.component.screen.LoadingScreen +import com.sixkids.designsystem.theme.Blue +import com.sixkids.designsystem.theme.BlueDark +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.model.Post +import com.sixkids.student.board.R +import com.sixkids.student.board.component.PostItem +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.util.formatToMonthDayTimeKorean + +private const val TAG = "D107" + +@Composable +fun StudentBoardMainRoute( + viewModel: StudentBoardMainViewModel = hiltViewModel(), + navigateToStudentBoardDetail: (postId:Long) -> Unit, + navigateToStudentBoardWrite: () -> Unit, + onShowSnackBar: (SnackbarToken) -> Unit, + padding: PaddingValues +) { + + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + Log.d(TAG, "StudentBoardMainRoute: ") + viewModel.getPostList() + } + + LaunchedEffect(key1 = viewModel.sideEffect) { + viewModel.sideEffect.collect { sideEffect -> + when (sideEffect) { + StudentBoardMainEffect.NavigateToPostDetail -> {} + StudentBoardMainEffect.NavigateToWritePost -> {} + is StudentBoardMainEffect.OnShowSnackBar -> { + onShowSnackBar(SnackbarToken(message = sideEffect.message)) + } + } + } + } + + Box( + modifier = Modifier + .padding(padding) + .fillMaxSize() + ) { + StudentBoardMainScreen( + postState = uiState, + postItems = viewModel.postList?.collectAsLazyPagingItems(), + postItemOnclick = navigateToStudentBoardDetail, + fabClick = navigateToStudentBoardWrite + ) + } +} + +@Composable +fun StudentBoardMainScreen( + modifier: Modifier = Modifier, + postState: StudentBoardMainState = StudentBoardMainState(), + postItems: LazyPagingItems? = null, + postItemOnclick: (postId: Long) -> Unit = {}, + fabClick: () -> Unit = {} +) { + val listState = rememberLazyListState() + + Box( + modifier = modifier + .fillMaxSize() + ) { + Column( + + ) { + UlbanDefaultAppBar( + leftIcon = UlbanRes.drawable.board, + title = stringResource(id = R.string.student_board_main_post), + content = stringResource(id = R.string.student_board_main_post), + body = postState.classString.replace("\n", " "), + color = Blue + ) + + if (postItems == null){ + Spacer(modifier = Modifier.weight(1f)) + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.student_board_post_no_items), + textAlign = TextAlign.Center, + style = UlbanTypography.bodyLarge, + ) + Spacer(modifier = Modifier.weight(1f)) + } else { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + state = listState, + ) { + items(postItems.itemCount) { index -> + postItems[index]?.let { post -> + PostItem( + title = post.title, + writer = post.writer, + dateString = post.time.formatToMonthDayTimeKorean(), + commentCount = post.commentCount, + onClick = { postItemOnclick(post.id) } + ) + } + } + } + } + } + //FAB + EditFAB( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(16.dp), + buttonColor = Blue, + iconColor = BlueDark, + onClick = fabClick + ) + if (postState.isLoding){ + LoadingScreen() + } + } +} + +@Preview(showBackground = true) +@Composable +fun StudentBoardMainScreenPreview() { + StudentBoardMainScreen() +} \ No newline at end of file diff --git a/android/feature/student/board/src/main/java/com/sixkids/student/board/main/StudentBoardMainViewModel.kt b/android/feature/student/board/src/main/java/com/sixkids/student/board/main/StudentBoardMainViewModel.kt new file mode 100644 index 00000000..1ddf48f6 --- /dev/null +++ b/android/feature/student/board/src/main/java/com/sixkids/student/board/main/StudentBoardMainViewModel.kt @@ -0,0 +1,54 @@ +package com.sixkids.student.board.main + +import android.util.Log +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn +import com.sixkids.domain.usecase.organization.GetSelectedOrganizationIdUseCase +import com.sixkids.domain.usecase.post.GetPostListUseCase +import com.sixkids.model.Post +import com.sixkids.model.PostCategory +import com.sixkids.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.launch +import javax.inject.Inject + +private const val TAG = "D107" +@HiltViewModel +class StudentBoardMainViewModel @Inject constructor( + private val getPostListUseCase: GetPostListUseCase, + private val getSelectedOrganizationIdUseCase: GetSelectedOrganizationIdUseCase +): BaseViewModel(StudentBoardMainState()) { + private var organizationId: Int? = null + + var postList: Flow>? = null + + fun getPostList() { + viewModelScope.launch { + intent { copy(isLoding = true) } + + if (organizationId == null){ + organizationId = getSelectedOrganizationIdUseCase().getOrNull() + Log.d(TAG, "getPostList1: $organizationId") + } + + Log.d(TAG, "getPostList: 2") + + if (organizationId != null){ + Log.d(TAG, "getPostList:3 ") + postList = getPostListUseCase( + organizationId = organizationId!!, + postCategory = PostCategory.FREE + ).cachedIn(viewModelScope).catch { + Log.d(TAG, "getPostList4: ${it.message}") + } + } else { + postSideEffect(StudentBoardMainEffect.OnShowSnackBar("학급 정보를 불러오지 못했어요 ;(")) + } + + intent { copy(isLoding = false) } + } + } +} \ No newline at end of file diff --git a/android/feature/student/board/src/main/java/com/sixkids/student/board/navigation/StudentBoardNavigation.kt b/android/feature/student/board/src/main/java/com/sixkids/student/board/navigation/StudentBoardNavigation.kt new file mode 100644 index 00000000..d5cea20b --- /dev/null +++ b/android/feature/student/board/src/main/java/com/sixkids/student/board/navigation/StudentBoardNavigation.kt @@ -0,0 +1,70 @@ +package com.sixkids.student.board.navigation + +import androidx.compose.foundation.layout.PaddingValues +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.NavType +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import com.sixkids.student.board.detail.StudentBoardDetailRoute +import com.sixkids.student.board.main.StudentBoardMainRoute +import com.sixkids.student.board.write.StudentBoardWriteRoute +import com.sixkids.ui.SnackbarToken + +fun NavController.navigateStudentBoard(navOptions: NavOptions) { + navigate(StudentBoardRoute.defaultRoute,navOptions) +} + +fun NavController.navigateStudentBoardWrite() { + navigate(StudentBoardRoute.writeRoute) +} + +fun NavController.navigateStudentBoardDetail(postId: Long) { + navigate(StudentBoardRoute.detailRoute(postId)) +} + +fun NavGraphBuilder.studentBoardNavGraph( + padding: PaddingValues, + onShowSnackBar: (SnackbarToken) -> Unit, + navigateToStudentBoardDetail: (postId:Long) -> Unit, + navigateToStudentBoardWrite: () -> Unit, + navigateBack: () -> Unit +) { + composable(route = StudentBoardRoute.defaultRoute) { + StudentBoardMainRoute( + padding = padding, + navigateToStudentBoardDetail = navigateToStudentBoardDetail, + navigateToStudentBoardWrite = navigateToStudentBoardWrite, + onShowSnackBar = onShowSnackBar + ) + } + + composable(route = StudentBoardRoute.writeRoute) { + StudentBoardWriteRoute( + padding = padding, + navigateBack = navigateBack, + onShowSnackBar = onShowSnackBar + ) + } + + composable( + route = StudentBoardRoute.detailRoute, + arguments = listOf(navArgument(StudentBoardRoute.postDetailARG) { type = NavType.LongType }) + ) { + StudentBoardDetailRoute( + padding = padding, + onShowSnackBar = onShowSnackBar + ) + } +} + +object StudentBoardRoute { + const val postDetailARG = "postId" + + const val defaultRoute = "student_board" + const val detailRoute = "student_board_detail/{$postDetailARG}" + const val writeRoute = "student_board_write" + + fun detailRoute(postId: Long) = "student_board_detail/$postId" +} \ No newline at end of file diff --git a/android/feature/student/board/src/main/java/com/sixkids/student/board/write/StudentBoardWriteContract.kt b/android/feature/student/board/src/main/java/com/sixkids/student/board/write/StudentBoardWriteContract.kt new file mode 100644 index 00000000..972f921e --- /dev/null +++ b/android/feature/student/board/src/main/java/com/sixkids/student/board/write/StudentBoardWriteContract.kt @@ -0,0 +1,18 @@ +package com.sixkids.student.board.write + +import android.graphics.Bitmap +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState + +sealed interface StudentBoardWriteEffect: SideEffect { + data object NavigateBack : StudentBoardWriteEffect + data class OnShowSnackbar(val message: String) : StudentBoardWriteEffect +} + +data class StudentBoardWriteState( + val isLoading: Boolean = false, + val title: String = "", + val content: String = "", + val anonymousChecked: Boolean = false, + val photo: Bitmap? = null +): UiState \ No newline at end of file diff --git a/android/feature/student/board/src/main/java/com/sixkids/student/board/write/StudentBoardWriteScreen.kt b/android/feature/student/board/src/main/java/com/sixkids/student/board/write/StudentBoardWriteScreen.kt new file mode 100644 index 00000000..34d2fd57 --- /dev/null +++ b/android/feature/student/board/src/main/java/com/sixkids/student/board/write/StudentBoardWriteScreen.kt @@ -0,0 +1,284 @@ +package com.sixkids.student.board.write + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.ImageDecoder +import android.os.Build +import android.provider.MediaStore +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +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.layout.wrapContentHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.sixkids.designsystem.R as UlbanRes +import com.sixkids.designsystem.component.button.UlbanFilledButton +import com.sixkids.designsystem.component.screen.LoadingScreen +import com.sixkids.designsystem.theme.Blue +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.student.board.R +import com.sixkids.student.board.component.PageTitle +import com.sixkids.ui.SnackbarToken +import java.io.File +import java.io.FileOutputStream +import java.io.IOException + +@Composable +fun StudentBoardWriteRoute( + viewModel: StudentBoardWriteViewModel = hiltViewModel(), + padding: PaddingValues, + navigateBack: () -> Unit, + onShowSnackBar: (SnackbarToken) -> Unit +) { + + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + val context = LocalContext.current + val photoLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickVisualMedia() + ) { uri -> + uri?.let { + try { + val bitmap = if (Build.VERSION.SDK_INT < 28) { + MediaStore.Images.Media.getBitmap(context.contentResolver, it) + } else { + ImageDecoder.decodeBitmap( + ImageDecoder.createSource( + context.contentResolver, + it + ) + ) + } + viewModel.onAddPhoto(bitmap) + } catch (e: IOException) { + viewModel.showToast("사진 호출에 실패했습니다.") + } + } + } + + LaunchedEffect(key1 = viewModel.sideEffect) { + viewModel.sideEffect.collect { sideEffect -> + when (sideEffect) { + StudentBoardWriteEffect.NavigateBack -> navigateBack() + is StudentBoardWriteEffect.OnShowSnackbar -> { + onShowSnackBar(SnackbarToken(message = sideEffect.message)) + } + } + } + } + + + + StudentBoardWriteScreen( + postWriteState = uiState, + cancelOnClick = { viewModel.onBack() }, + submitOnClick = { + viewModel.onPost( + uiState.photo?.let { saveBitmapToFile(context, it, "post_photo.jpg") } + ) + }, + titleValueChange = { viewModel.onTitleChanged(it) }, + contentValueChange = { viewModel.onContentChanged(it) }, + anonymousCheckedChange = { viewModel.onAnonymousChecked(it) }, + addPhotoOnClick = { photoLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) } + ) +} + +@Composable +fun StudentBoardWriteScreen( + modifier: Modifier = Modifier, + postWriteState: StudentBoardWriteState = StudentBoardWriteState(), + cancelOnClick: () -> Unit = {}, + submitOnClick: () -> Unit = {}, + titleValueChange: (String) -> Unit = {}, + contentValueChange: (String) -> Unit = {}, + anonymousCheckedChange: (Boolean) -> Unit = {}, + addPhotoOnClick: () -> Unit = {} +) { + + val scrollState = rememberScrollState() + + LaunchedEffect(postWriteState.content) { + scrollState.scrollTo(scrollState.maxValue) + } + + Box { + Column( + modifier = modifier + .fillMaxSize() + .padding(20.dp) + ) { + Spacer(modifier = Modifier.height(20.dp)) + PageTitle( + title = stringResource(id = R.string.student_board_write_title), + cancelOnclick = cancelOnClick + ) + //title + OutlinedTextField( + value = postWriteState.title, + onValueChange = titleValueChange, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent + ), + placeholder = { + Text( + text = stringResource(id = R.string.student_board_write_content_title), + style = UlbanTypography.bodyLarge + ) + }, + textStyle = UlbanTypography.bodyLarge + ) + HorizontalDivider( + thickness = 2.dp, + color = Color.Black + ) + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .verticalScroll(scrollState) + ) { + //photo + if (postWriteState.photo != null) { + Spacer(modifier = Modifier.height(10.dp)) + Image( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f), + bitmap = postWriteState.photo.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Crop + ) + } + //content + OutlinedTextField( + value = postWriteState.content, + onValueChange = { string -> + contentValueChange(string) + }, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent + ), + placeholder = { + Text( + text = stringResource(id = R.string.student_board_write_content_content), + style = UlbanTypography.bodyLarge + ) + }, + textStyle = UlbanTypography.bodyLarge + ) + } + Row( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(), + verticalAlignment = Alignment.CenterVertically + ) { + // 이미지 추가 아이콘 + Icon( + modifier = Modifier + .size(40.dp) + .clickable(onClick = addPhotoOnClick), + imageVector = ImageVector.vectorResource(id = UlbanRes.drawable.ic_photo_camera), + contentDescription = null + ) + Spacer(modifier = Modifier.width(10.dp)) + // 익명 체크박스 + Checkbox( + modifier = Modifier.scale(1.2f), + checked = postWriteState.anonymousChecked, + onCheckedChange = anonymousCheckedChange, + colors = CheckboxDefaults.colors( + checkedColor = Blue, + uncheckedColor = Color.Black + ) + ) + Text( + text = stringResource(id = R.string.student_board_write_anonymous), + style = UlbanTypography.bodyLarge + ) + Spacer(modifier = Modifier.weight(1f)) + // 등록 버튼 + UlbanFilledButton( + text = stringResource(id = R.string.student_board_write_submit), + onClick = submitOnClick + ) + } + } + + if (postWriteState.isLoading) { + LoadingScreen() + } + } +} + + +fun saveBitmapToFile(context: Context, bitmap: Bitmap?, fileName: String): File? { + val directory = context.getExternalFilesDir(null) ?: return null + + val file = File(directory, fileName) + var fileOutputStream: FileOutputStream? = null + + try { + fileOutputStream = FileOutputStream(file) + bitmap?.compress(Bitmap.CompressFormat.JPEG, 100, fileOutputStream) + fileOutputStream.flush() + } catch (e: Exception) { + e.printStackTrace() + return null + } finally { + try { + fileOutputStream?.close() + } catch (e: Exception) { + e.printStackTrace() + } + } + + return file +} + +@Preview(showBackground = true) +@Composable +fun StudentBoardWriteScreenPreview() { + StudentBoardWriteScreen() +} \ No newline at end of file diff --git a/android/feature/student/board/src/main/java/com/sixkids/student/board/write/StudentBoardWriteViewModel.kt b/android/feature/student/board/src/main/java/com/sixkids/student/board/write/StudentBoardWriteViewModel.kt new file mode 100644 index 00000000..7688b459 --- /dev/null +++ b/android/feature/student/board/src/main/java/com/sixkids/student/board/write/StudentBoardWriteViewModel.kt @@ -0,0 +1,57 @@ +package com.sixkids.student.board.write + +import android.graphics.Bitmap +import androidx.lifecycle.viewModelScope +import com.sixkids.domain.usecase.organization.GetSelectedOrganizationIdUseCase +import com.sixkids.domain.usecase.post.NewPostUseCase +import com.sixkids.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import java.io.File +import javax.inject.Inject + +@HiltViewModel +class StudentBoardWriteViewModel @Inject constructor( + private val newPostUseCase: NewPostUseCase, + private val getSelectedOrganizationIdUseCase: GetSelectedOrganizationIdUseCase +): BaseViewModel(StudentBoardWriteState()){ + + private var organizationId: Int? = null + + fun onBack() = postSideEffect(StudentBoardWriteEffect.NavigateBack) + fun onTitleChanged(title: String) = intent { copy(title = title) } + fun onContentChanged(content: String) = intent { copy(content = content) } + fun onAnonymousChecked(checked: Boolean) = intent { copy(anonymousChecked = checked) } + fun onAddPhoto(bitmap: Bitmap) = intent { copy(photo = bitmap) } + fun showToast(message: String) = postSideEffect(StudentBoardWriteEffect.OnShowSnackbar(message)) + + fun onPost(photo: File?) { + viewModelScope.launch { + intent { copy(isLoading = true) } + + if (organizationId == null) { + organizationId = getSelectedOrganizationIdUseCase().getOrNull() + } + + if (organizationId != null) { + newPostUseCase( + organizationId = organizationId!!.toLong(), + title = currentState.title, + content = currentState.content, + secretStatus = currentState.anonymousChecked, + postCategory = "FREE", + file = photo + ).onSuccess { + postSideEffect(StudentBoardWriteEffect.OnShowSnackbar("게시글 작성에 성공했어요 :)")) + postSideEffect(StudentBoardWriteEffect.NavigateBack) + }.onFailure { + postSideEffect(StudentBoardWriteEffect.OnShowSnackbar(it.message ?: "게시글 작성에 실패했어요 ;(")) + } + } else { + postSideEffect(StudentBoardWriteEffect.OnShowSnackbar("학급 정보를 불러오지 못했어요 ;(")) + } + + intent { copy(isLoading = false) } + } + } +} \ No newline at end of file diff --git a/android/feature/student/board/src/main/res/values/strings.xml b/android/feature/student/board/src/main/res/values/strings.xml new file mode 100644 index 00000000..676dd2a7 --- /dev/null +++ b/android/feature/student/board/src/main/res/values/strings.xml @@ -0,0 +1,14 @@ + + + 자유게시판 + + 글쓰기 + 제목 + 내용을 입력하세요 + 익명 + 게시 + + 게시글이 없어용! + + 댓글을 입력하세요 + \ No newline at end of file diff --git a/android/feature/student/challenge/build.gradle.kts b/android/feature/student/challenge/build.gradle.kts index 14fc159d..c28438d0 100644 --- a/android/feature/student/challenge/build.gradle.kts +++ b/android/feature/student/challenge/build.gradle.kts @@ -1,10 +1,37 @@ +import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties +import java.util.Properties + plugins { alias(libs.plugins.sixkids.android.feature.compose) + alias(libs.plugins.kotlin.serialization) } +fun getProperty(propertyKey: String): String = + gradleLocalProperties(rootDir, providers).getProperty(propertyKey) + android { namespace = "com.sixkids.student.challenge" + defaultConfig { + val localProperties = Properties() + val localPropertiesFile = rootProject.file("local.properties") + if (localPropertiesFile.exists()) { + localProperties.load(localPropertiesFile.inputStream()) + } + + val sseUrl = localProperties.getProperty("SSE_ENDPOINT") ?: "" + + buildConfigField("String", "SSE_ENDPOINT", "\"${sseUrl}\"") + } + + buildFeatures { + buildConfig = true + } } dependencies { + implementation(libs.bundles.paging) + implementation(projects.core.bluetooth) + implementation(libs.okhttp.sse) + implementation(libs.okhttp.logginginterceptor) + implementation(libs.kotlinx.serialization.json) } diff --git a/android/feature/student/challenge/src/main/AndroidManifest.xml b/android/feature/student/challenge/src/main/AndroidManifest.xml index a5918e68..87167fef 100644 --- a/android/feature/student/challenge/src/main/AndroidManifest.xml +++ b/android/feature/student/challenge/src/main/AndroidManifest.xml @@ -1,4 +1,7 @@ - \ No newline at end of file + + + + diff --git a/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/challeng/history/ChallengeHistoryContract.kt b/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/challeng/history/ChallengeHistoryContract.kt new file mode 100644 index 00000000..85571e4e --- /dev/null +++ b/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/challeng/history/ChallengeHistoryContract.kt @@ -0,0 +1,34 @@ +package com.sixkids.student.challeng.history + +import androidx.paging.PagingData +import com.sixkids.model.Challenge +import com.sixkids.model.GroupType +import com.sixkids.model.MemberSimple +import com.sixkids.model.RunningChallengeByStudent +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState +import kotlinx.coroutines.flow.Flow + +data class ChallengeHistoryState( + val isLoading: Boolean = false, + val runningChallenge: RunningChallengeByStudent? = null, + val totalChallengeCount: Int = 0, + val organizationId: Int = 0, + val challengeHistory: Flow>? = null +) : UiState + +sealed interface ChallengeHistoryEffect : SideEffect { + data class NavigateToChallengeDetail(val challengeId: Long, val groupId: Long? = null) : + ChallengeHistoryEffect + + data class NavigateToCreateGroup(val challengeId: Long, val groupType: GroupType) : + ChallengeHistoryEffect + + data class NavigateToMathedGroupCreate(val challengeId: Long, val members: List) : + ChallengeHistoryEffect + + data class NavigateToJoinGroup(val challengeId: Long) : ChallengeHistoryEffect + data object ShowGroupDialog : ChallengeHistoryEffect + data class HandleException(val throwable: Throwable, val retry: () -> Unit) : + ChallengeHistoryEffect +} diff --git a/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/challeng/history/ChallengeHistoryScreen.kt b/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/challeng/history/ChallengeHistoryScreen.kt new file mode 100644 index 00000000..294cacea --- /dev/null +++ b/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/challeng/history/ChallengeHistoryScreen.kt @@ -0,0 +1,242 @@ +package com.sixkids.student.challeng.history + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.paging.compose.collectAsLazyPagingItems +import com.sixkids.designsystem.component.appbar.UlbanDefaultAppBar +import com.sixkids.designsystem.component.item.UlbanChallengeItem +import com.sixkids.designsystem.theme.Red +import com.sixkids.designsystem.theme.UlbanTheme +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.model.GroupType +import com.sixkids.model.MemberSimple +import com.sixkids.student.challeng.history.component.GroupParticipationDialog +import com.sixkids.student.challeng.history.component.UlbanStudentRunningChallengeAppBar +import com.sixkids.student.challenge.R +import com.sixkids.ui.util.formatToMonthDayTime +import com.sixkids.designsystem.R as DesignSystemR + +@Composable +fun ChallengeRoute( + viewModel: ChallengeHistoryViewModel = hiltViewModel(), + navigateToDetail: (Long, Long?) -> Unit, + navigateToCreateGroup: (Long, GroupType) -> Unit, + navigateToMatchedGroupCreate: (Long, List) -> Unit, + navigateToJoinGroup: (Long) -> Unit, + handleException: (Throwable, () -> Unit) -> Unit +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(key1 = Unit) { + viewModel.initData() + } + + var showDialog by remember { + mutableStateOf(false) + } + + LaunchedEffect(key1 = viewModel.sideEffect) { + viewModel.sideEffect.collect { sideEffect -> + when (sideEffect) { + is ChallengeHistoryEffect.NavigateToChallengeDetail -> navigateToDetail( + sideEffect.challengeId, + sideEffect.groupId + ) + + is ChallengeHistoryEffect.HandleException -> handleException( + sideEffect.throwable, + sideEffect.retry + ) + + ChallengeHistoryEffect.ShowGroupDialog -> { + showDialog = true + } + + is ChallengeHistoryEffect.NavigateToCreateGroup -> navigateToCreateGroup( + sideEffect.challengeId, + sideEffect.groupType + ) + + is ChallengeHistoryEffect.NavigateToJoinGroup -> navigateToJoinGroup(sideEffect.challengeId) + is ChallengeHistoryEffect.NavigateToMathedGroupCreate -> navigateToMatchedGroupCreate( + sideEffect.challengeId, + sideEffect.members + ) + } + } + } + + ChallengeHistoryScreen( + uiState = uiState, + updateTotalCount = viewModel::updateTotalCount, + navigateToDetail = viewModel::navigateChallengeDetail, + navigateToMatchedGroupCrateOrJoin = viewModel::navigateToCrateOrJoinGroup, + showDialog = viewModel::showGroupDialog, + ) + + if (showDialog) { + GroupParticipationDialog( + onCreateGroupClick = viewModel::navigateToCreateGroup, + onJoinGroupClick = viewModel::navigateToJoinGroup, + onDismiss = { showDialog = false } + ) + } +} + +@Composable +fun ChallengeHistoryScreen( + uiState: ChallengeHistoryState = ChallengeHistoryState(), + updateTotalCount: (Int) -> Unit = {}, + navigateToDetail: (Long) -> Unit = {}, + navigateToMatchedGroupCrateOrJoin: (Boolean) -> Unit = {}, + showDialog: () -> Unit = {}, +) { + val listState = rememberLazyListState() + val isScrolled by remember { + derivedStateOf { + listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 100 + } + } + + val challengeItems = uiState.challengeHistory?.collectAsLazyPagingItems() + + Column( + modifier = Modifier + .fillMaxSize() + ) { + val runningChallenge = uiState.runningChallenge?.challenge + if (runningChallenge == null) { + UlbanDefaultAppBar( + leftIcon = com.sixkids.designsystem.R.drawable.hifive, + title = stringResource(id = R.string.hifive_challenge), + content = stringResource(R.string.no_running_challenge), + color = Red, + expanded = !isScrolled + ) + } else { + UlbanStudentRunningChallengeAppBar( + leftIcon = DesignSystemR.drawable.hifive, + title = stringResource(id = R.string.hifive_challenge), + content = runningChallenge.title, + topDescription = "${runningChallenge.startTime.formatToMonthDayTime()} ~ ${runningChallenge.endTime.formatToMonthDayTime()}", + bottomDescription = runningChallenge.content, + color = Red, + onClick = if (uiState.runningChallenge.createTime == null) { + if (uiState.runningChallenge.type == GroupType.FREE) { + showDialog + } else { + { + navigateToMatchedGroupCrateOrJoin( + uiState.runningChallenge.leaderStatus ?: false + ) + } + } + } else { + {} + }, + onReportEnable = (uiState.runningChallenge.leaderStatus == true && uiState.runningChallenge.endStatus?.not() ?: false), + onReportClick = { + //TODO 과제 제출로 이동 + }, + teamDescription = if (uiState.runningChallenge.type == GroupType.DESIGN) { + uiState.runningChallenge.memberNames.joinToString(", ") { it.name } + } else { + if (uiState.runningChallenge.memberNames.isNotEmpty()) { + uiState.runningChallenge.memberNames.joinToString(", ") { it.name } + } else { + stringResource(R.string.free_group_matching) + } + }, + runningTimeDescription = "과제 참여 후 진행시간 표시하기", + expanded = !isScrolled + ) + } + Spacer(modifier = Modifier.padding(12.dp)) + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + ) { + Text( + modifier = Modifier.padding(horizontal = 4.dp), + text = stringResource( + id = R.string.total_challenge_count, + uiState.totalChallengeCount + ), + style = UlbanTypography.bodyMedium.copy(fontWeight = FontWeight.Bold) + ) + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + + + if (challengeItems == null || challengeItems.itemCount == 0) { + Spacer(modifier = Modifier.weight(1f)) + Text( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + text = stringResource(id = R.string.no_challenge_history), + style = UlbanTypography.titleLarge, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.weight(1f)) + } else { + LazyColumn( + modifier = Modifier.weight(1f), + state = listState, + contentPadding = PaddingValues(vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + items(challengeItems.itemCount) { index -> + challengeItems[index]?.let { challenge -> + if (index == 0) { + updateTotalCount(challenge.totalCount) + } + UlbanChallengeItem( + title = challenge.title, + description = challenge.content, + startDate = challenge.startTime, + endDate = challenge.endTime, + userCount = challenge.headCount, + onClick = { navigateToDetail(challenge.id) } + ) + } + } + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun MyPageDefaultScreenPreview() { + UlbanTheme { + ChallengeHistoryScreen() + } +} diff --git a/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/challeng/history/ChallengeHistoryViewModel.kt b/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/challeng/history/ChallengeHistoryViewModel.kt new file mode 100644 index 00000000..f37019ac --- /dev/null +++ b/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/challeng/history/ChallengeHistoryViewModel.kt @@ -0,0 +1,92 @@ +package com.sixkids.student.challeng.history + +import androidx.lifecycle.viewModelScope +import com.sixkids.domain.usecase.challenge.GetChallengeHistoryUseCase +import com.sixkids.domain.usecase.challenge.GetRunningChallengeByStudentUseCase +import com.sixkids.domain.usecase.organization.GetSelectedOrganizationIdUseCase +import com.sixkids.domain.usecase.user.GetUserInfoUseCase +import com.sixkids.model.UserInfo +import com.sixkids.ui.base.BaseViewModel +import com.sixkids.ui.extension.flatMap +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ChallengeHistoryViewModel @Inject constructor( + private val getSelectedOrganizationIdUseCase: GetSelectedOrganizationIdUseCase, + private val getUserInfoUseCase: GetUserInfoUseCase, + private val getChallengeHistoryUseCase: GetChallengeHistoryUseCase, + private val getRunningChallengeByStudentUseCase: GetRunningChallengeByStudentUseCase +) : BaseViewModel( + ChallengeHistoryState() +) { + private var isFirstVisited: Boolean = true + private var organizationId: Long = 0 + private lateinit var userInfo: UserInfo + fun initData() = viewModelScope.launch { + if (isFirstVisited.not()) return@launch + isFirstVisited = false + + intent { copy(isLoading = true) } + + getUserInfoUseCase().flatMap { userInfo -> + this@ChallengeHistoryViewModel.userInfo = userInfo + getSelectedOrganizationIdUseCase().flatMap { organizationId -> + this@ChallengeHistoryViewModel.organizationId = organizationId.toLong() + val challengeHistory = getChallengeHistoryUseCase(organizationId, userInfo.id) + intent { copy(challengeHistory = challengeHistory) } + getRunningChallengeByStudentUseCase(organizationId) + }.onSuccess { + intent { copy(isLoading = false, runningChallenge = it) } + }.onFailure { + } + } + + intent { copy(isLoading = false) } + } + + fun navigateChallengeDetail(challengeId: Long) = postSideEffect( + ChallengeHistoryEffect.NavigateToChallengeDetail(challengeId) + ) + + fun showGroupDialog() = postSideEffect( + ChallengeHistoryEffect.ShowGroupDialog + ) + + fun navigateToCreateGroup() { + val runningChallenge = uiState.value.runningChallenge ?: return + postSideEffect( + ChallengeHistoryEffect.NavigateToCreateGroup( + runningChallenge.challenge.id, + runningChallenge.type + ) + ) + } + + private fun navigateToMatchedGroupCreate(){ + val runningChallenge = uiState.value.runningChallenge ?: return + postSideEffect( + ChallengeHistoryEffect.NavigateToMathedGroupCreate( + runningChallenge.challenge.id, + runningChallenge.memberNames + ) + ) + } + + fun navigateToJoinGroup() = postSideEffect( + ChallengeHistoryEffect.NavigateToJoinGroup(organizationId) + ) + + fun updateTotalCount(totalCount: Int) { + intent { copy(totalChallengeCount = totalCount) } + } + + fun navigateToCrateOrJoinGroup(leaderStatus: Boolean) { + if (leaderStatus) { + navigateToMatchedGroupCreate() + } else { + navigateToJoinGroup() + } + } +} diff --git a/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/challeng/history/component/GroupParticipationDialog.kt b/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/challeng/history/component/GroupParticipationDialog.kt new file mode 100644 index 00000000..7772c19d --- /dev/null +++ b/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/challeng/history/component/GroupParticipationDialog.kt @@ -0,0 +1,103 @@ +package com.sixkids.student.challeng.history.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.sixkids.designsystem.component.button.UlbanFilledButton +import com.sixkids.designsystem.component.dialog.UlbanBasicDialog +import com.sixkids.designsystem.theme.Green +import com.sixkids.designsystem.theme.Orange +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.student.challenge.R + +@Composable +fun GroupParticipationDialog( + modifier: Modifier = Modifier, + onCreateGroupClick: () -> Unit, + onJoinGroupClick: () -> Unit, + onDismiss: () -> Unit +) { + UlbanBasicDialog( + onDismiss = onDismiss, + ) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.End + ) { + Row( + modifier = Modifier.width(240.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + TextButton( + modifier = Modifier + .width(100.dp) + .height(140.dp), + onClick = onCreateGroupClick, + colors = ButtonDefaults.textButtonColors( + containerColor = Orange + ), + shape = RoundedCornerShape(16.dp) + ) { + Text( + text = stringResource(R.string.create_group), + textAlign = TextAlign.Center, + color = Color.Black, + style = UlbanTypography.titleSmall + ) + } + TextButton( + modifier = Modifier + .width(100.dp) + .height(140.dp), + onClick = onJoinGroupClick, + colors = ButtonDefaults.textButtonColors( + containerColor = Green + ), + shape = RoundedCornerShape(16.dp) + ) { + Text( + text = stringResource(R.string.join_group), + textAlign = TextAlign.Center, + color = Color.Black, + style = UlbanTypography.titleSmall + ) + } + } + Spacer(modifier = Modifier.height(16.dp)) + UlbanFilledButton( + text = stringResource(R.string.cancel), + onClick = onDismiss + ) + } + + } +} + +@Preview(showBackground = true) +@Composable +fun GroupParticipationDialogPreview() { + GroupParticipationDialog( + onCreateGroupClick = {}, + onJoinGroupClick = {}, + onDismiss = {} + ) +} diff --git a/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/challeng/history/component/UlbanStudentRunningChallengeAppBar.kt b/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/challeng/history/component/UlbanStudentRunningChallengeAppBar.kt new file mode 100644 index 00000000..a8216d00 --- /dev/null +++ b/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/challeng/history/component/UlbanStudentRunningChallengeAppBar.kt @@ -0,0 +1,109 @@ +package com.sixkids.student.challeng.history.component + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.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.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.sixkids.designsystem.component.appbar.AppBarDetailInfo +import com.sixkids.designsystem.component.appbar.BasicAppBar +import com.sixkids.designsystem.component.button.UlbanFilledButton +import com.sixkids.designsystem.theme.Cream +import com.sixkids.designsystem.theme.Red +import com.sixkids.designsystem.theme.RedDark +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.student.challenge.R + +@Composable +fun UlbanStudentRunningChallengeAppBar( + modifier: Modifier = Modifier, + @DrawableRes leftIcon: Int, + title: String, + content: String, + topDescription: String, + bottomDescription: String, + teamDescription: String, + runningTimeDescription: String, + color: Color, + expanded: Boolean = true, + onReportEnable: Boolean = true, + onReportClick: () -> Unit, + onClick: () -> Unit = {} + +) { + BasicAppBar( + modifier = modifier, + leftIcon = leftIcon, + title = title, + content = { + Column( + modifier.fillMaxWidth(), + horizontalAlignment = Alignment.Start + ) { + if(onReportEnable){ + Row { + Spacer(modifier = Modifier.weight(1f)) + UlbanFilledButton( + text = stringResource(R.string.end), + modifier = Modifier.padding(end = 16.dp), + color = RedDark, + textColor = Cream, + textStyle = UlbanTypography.titleSmall.copy(fontSize = 12.sp), + onClick = onReportClick + ) + } + } + + AppBarDetailInfo( + title = content, + topDescription = topDescription, + bottomDescription = bottomDescription, + ) + Text( + modifier = Modifier.padding(top = 8.dp), + text = stringResource(R.string.team_member), + style = UlbanTypography.titleSmall.copy(fontSize = 12.sp) + ) + Text( + text = teamDescription, + style = UlbanTypography.bodySmall + ) +// Text( +// modifier = Modifier.padding(top = 12.dp), +// text = runningTimeDescription, +// style = UlbanTypography.titleSmall +// ) + } + }, + color = color, + expanded = expanded, + onclick = onClick + ) +} + + +@Preview(showBackground = true) +@Composable +private fun UlbanStudentRunningChallengeAppBarPreview() { + UlbanStudentRunningChallengeAppBar( + leftIcon = com.sixkids.designsystem.R.drawable.hifive, + title = "title", + content = "4월 22일 함께 달리기", + topDescription = "04.17 15:00 ~ 04.19 20:00", + bottomDescription = "문화의 날을 맞아 우리반 친구들 4명 이상 만나서 영화를 보자", + color = Red, + teamDescription = "4명 이상 자율적으로 구성해 봐요", + runningTimeDescription = "1시간 20분째 진행 중입니다", + onReportClick = {} + ) +} diff --git a/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/group/component/GroupWaiting.kt b/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/group/component/GroupWaiting.kt new file mode 100644 index 00000000..d5116bb0 --- /dev/null +++ b/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/group/component/GroupWaiting.kt @@ -0,0 +1,147 @@ +package com.sixkids.student.group.component + +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.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.sixkids.designsystem.component.button.UlbanFilledButton +import com.sixkids.designsystem.theme.Cream +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.model.GroupType +import com.sixkids.model.MemberSimple +import com.sixkids.student.challenge.R + +@Composable +fun GroupWaiting( + leader: MemberSimple = MemberSimple(), + memberList: List = emptyList(), + waitingMemberList: List = emptyList(), + onDoneClick: () -> Unit = {}, + onRemoveClick: (Long) -> Unit = {}, + groupSize: Int = 0, + groupType: GroupType = GroupType.FREE +) { + Card( + modifier = Modifier + .padding(top = 16.dp) + .fillMaxWidth(), + shape = RoundedCornerShape( + topStart = 12.dp, + topEnd = 12.dp, + bottomStart = 0.dp, + bottomEnd = 0.dp + ), + elevation = CardDefaults.cardElevation(16.dp), + colors = CardDefaults.cardColors( + containerColor = Cream + ) + ) { + val remainingMember = groupSize - memberList.size - 1 + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + if (groupType == GroupType.FREE) { + Text( + modifier = Modifier.padding(top = 16.dp), + text = if (remainingMember > 0) { + stringResource(R.string.friend_waiting_message, remainingMember) + } else { + stringResource(R.string.can_create_group) + }, + style = UlbanTypography.bodyMedium + ) + } else { + Text( + modifier = Modifier.padding(top = 16.dp), + text = if (remainingMember != 0) { + stringResource(R.string.need_to_waiting_friend_message, remainingMember) + } else { + stringResource(R.string.can_create_group) + }, + style = UlbanTypography.bodyMedium + ) + } + + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (leader.id != 0L) { + item { + MemberIcon( + memberIconItem = MemberIconItem( + member = leader, + showX = false, + isActive = true + ), + onRemoveClick = {} + ) + } + } + items(memberList) { item -> + MemberIcon( + memberIconItem = MemberIconItem( + member = item, + showX = true, + isActive = true, + ), + onRemoveClick = onRemoveClick + ) + } + items(waitingMemberList){ + MemberIcon( + memberIconItem = MemberIconItem( + member = it, + showX = false, + isActive = false + ) + ) + } + } + UlbanFilledButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.done), + onClick = onDoneClick, + enabled = remainingMember <= 0 + ) + } + } +} + +data class MemberIconItem( + val member: MemberSimple = MemberSimple(), + val showX: Boolean = false, + val isActive: Boolean = false +) + + +@Preview(showBackground = true) +@Composable +fun GroupWaitingPreview() { + GroupWaiting( + leader = MemberSimple( + id = 1, + name = "leader", + photo = "" + ), + memberList = List(4) { + MemberSimple( + id = it.toLong(), + name = "member $it", + photo = "", + ) + }, + groupSize = 5 + ) +} diff --git a/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/group/component/InviteDialog.kt b/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/group/component/InviteDialog.kt new file mode 100644 index 00000000..237a6ec2 --- /dev/null +++ b/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/group/component/InviteDialog.kt @@ -0,0 +1,79 @@ +package com.sixkids.student.group.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.sixkids.designsystem.component.button.UlbanFilledButton +import com.sixkids.designsystem.component.dialog.UlbanBasicDialog +import com.sixkids.designsystem.theme.Red +import com.sixkids.model.MemberSimple +import com.sixkids.student.challenge.R +import com.sixkids.designsystem.R as DesignSystemR + +@Composable +fun InviteDialog( + leader: MemberIconItem, + modifier: Modifier = Modifier, + onConfirmClick: () -> Unit = {}, + onCancelClick: () -> Unit = {} +) { + + UlbanBasicDialog( + modifier = modifier, + ) { + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + MemberIcon(modifier = Modifier.padding(top = 4.dp), memberIconItem = leader) + Text( + modifier = Modifier.padding(vertical = 16.dp), + text = stringResource(R.string.invited_group) + ) + + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + + UlbanFilledButton( + modifier = Modifier.weight(1f), + text = stringResource(id = DesignSystemR.string.cancel), + onClick = onCancelClick, + color = Red + ) + UlbanFilledButton( + modifier = Modifier.weight(1f), + text = stringResource(id = DesignSystemR.string.confirm), + onClick = onConfirmClick + ) + } + } + } + +} + +@Preview(showBackground = true) +@Composable +private fun InviteDialogPreview() { + InviteDialog( + leader = MemberIconItem( + member = MemberSimple( + id = 1, + name = "Leader", + photo = "https://www.example.com/image.jpg" + ), + isActive = true + ) + ) +} diff --git a/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/group/component/MatchedGroupWaiting.kt b/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/group/component/MatchedGroupWaiting.kt new file mode 100644 index 00000000..58f24d9c --- /dev/null +++ b/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/group/component/MatchedGroupWaiting.kt @@ -0,0 +1,127 @@ +package com.sixkids.student.group.component + +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.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.sixkids.designsystem.component.button.UlbanFilledButton +import com.sixkids.designsystem.theme.Cream +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.model.MemberSimple +import com.sixkids.student.challenge.R + +@Composable +fun MatchedGroupWaiting( + leader: MemberSimple = MemberSimple(), + memberList: List = emptyList(), + waitingMemberList: List = emptyList(), + onDoneClick: () -> Unit = {}, + onRemoveClick: (Long) -> Unit = {}, + groupSize: Int = 0, +) { + Card( + modifier = Modifier + .padding(top = 16.dp) + .fillMaxWidth(), + shape = RoundedCornerShape( + topStart = 12.dp, + topEnd = 12.dp, + bottomStart = 0.dp, + bottomEnd = 0.dp + ), + elevation = CardDefaults.cardElevation(16.dp), + colors = CardDefaults.cardColors( + containerColor = Cream + ) + ) { + val remainingMember = groupSize - memberList.size - 1 + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + modifier = Modifier.padding(top = 16.dp), + text = if (remainingMember != 0) { + stringResource(R.string.need_to_waiting_friend_message, remainingMember) + } else { + stringResource(R.string.can_create_group) + }, + style = UlbanTypography.bodyMedium + ) + + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (leader.id != 0L) { + item { + MemberIcon( + memberIconItem = MemberIconItem( + member = leader, + showX = false, + isActive = true + ), + onRemoveClick = {} + ) + } + } + items(memberList){ + MemberIcon( + memberIconItem = MemberIconItem( + member = it, + showX = false, + isActive = true + ), + onRemoveClick = onRemoveClick + ) + } + items(waitingMemberList){ + MemberIcon( + memberIconItem = MemberIconItem( + member = it, + showX = false, + isActive = false + ) + ) + } + } + UlbanFilledButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.done), + onClick = onDoneClick, + enabled = remainingMember == 0 + ) + } + } +} + + +@Preview(showBackground = true) +@Composable +fun MatchedGroupWaitingPreview() { + MatchedGroupWaiting( + leader = MemberSimple( + id = 1, + name = "leader", + photo = "" + ), + waitingMemberList = List(4) { + MemberSimple( + id = it.toLong(), + name = "member $it", + photo = "", + ) + }, + groupSize = 5 + ) +} diff --git a/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/group/component/MemberIcon.kt b/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/group/component/MemberIcon.kt new file mode 100644 index 00000000..fed8e88b --- /dev/null +++ b/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/group/component/MemberIcon.kt @@ -0,0 +1,116 @@ +package com.sixkids.student.group.component + +import androidx.compose.foundation.background +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.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.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.model.MemberSimple +import com.sixkids.designsystem.R as DesignSystemR + +@Composable +fun MemberIcon( + modifier: Modifier = Modifier, + memberIconItem: MemberIconItem, + onIconClick: (MemberIconItem) -> Unit = {}, + onRemoveClick: (Long) -> Unit = {}, +) { + Card( + modifier = modifier + .wrapContentSize() + .background( + if (memberIconItem.isActive) Color.Transparent else Color.Gray, + shape = RoundedCornerShape(8.dp), + ) + .graphicsLayer { + alpha = if (memberIconItem.isActive) 1f else 0.5f + }, + colors = CardDefaults.cardColors(containerColor = Color.Transparent), + + onClick = { onIconClick(memberIconItem) } + ) { + Box(modifier.wrapContentSize()) { + + Column( + modifier = modifier.wrapContentSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Box( + modifier = modifier.wrapContentSize(), + ) { + Card { + AsyncImage( + model = memberIconItem.member.photo, + contentDescription = null, + modifier = modifier.size(48.dp), + contentScale = ContentScale.Crop + ) + } + if (memberIconItem.showX) { + Icon( + imageVector = ImageVector.vectorResource(DesignSystemR.drawable.ic_close_filled), + contentDescription = "Close icon", + tint = Color.Red, + modifier = Modifier + .align(Alignment.TopEnd) + .size(24.dp) + .clickable { + onRemoveClick(memberIconItem.member.id) + } + ) + } + } + Text( + text = memberIconItem.member.name, + style = UlbanTypography.bodyMedium, + modifier = Modifier.padding(4.dp), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + + +} + +@Preview(showBackground = true) +@Composable +fun MemberIconPreview() { + MemberIcon( + memberIconItem = MemberIconItem( + member = MemberSimple( + id = 1, + name = "Leader", + photo = "https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png" + + ), + showX = true, + isActive = true + ), + onIconClick = {}, + onRemoveClick = {} + ) +} diff --git a/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/group/component/MultiLayeredCircles.kt b/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/group/component/MultiLayeredCircles.kt new file mode 100644 index 00000000..4f46a8f2 --- /dev/null +++ b/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/group/component/MultiLayeredCircles.kt @@ -0,0 +1,40 @@ +package com.sixkids.student.group.component + +import androidx.compose.foundation.Canvas +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.sixkids.designsystem.theme.Blue +import com.sixkids.designsystem.theme.Green +import com.sixkids.designsystem.theme.Orange +import com.sixkids.designsystem.theme.Red +import com.sixkids.designsystem.theme.UlbanTheme + +@Composable +fun MultiLayeredCircles(modifier: Modifier = Modifier) { + Canvas(modifier = modifier ){ + val strokeWidth = 2.dp.toPx() + val radiusIncrement = 46.dp.toPx() // 각 원의 반지름 증가량 + + val colors = listOf(Red, Orange, Green, Blue) // 원의 색상 리스트 + + for (i in colors.indices) { + drawCircle( + color = colors[i], + radius = radiusIncrement * (i + 1), + style = Stroke(width = strokeWidth) + ) + } + } +} + + +@Preview(showBackground = true) +@Composable +fun MultiLayeredCirclesPreview() { + UlbanTheme { + MultiLayeredCircles() + } +} diff --git a/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/group/create/free/CreateGroupContract.kt b/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/group/create/free/CreateGroupContract.kt new file mode 100644 index 00000000..1b9bc0f4 --- /dev/null +++ b/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/group/create/free/CreateGroupContract.kt @@ -0,0 +1,27 @@ +package com.sixkids.student.group.create.free + +import com.sixkids.model.MemberSimple +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState + +data class CreateGroupState( + val isLoading: Boolean = false, + val isScanning: Boolean = false, + val foundMembers: List = emptyList(), + val showMembers: Array = Array(5) { null }, + val selectedMembers: List = emptyList(), + val waitingMembers: List = emptyList(), + val groupSize: Int = 0, + val leader: MemberSimple = MemberSimple(), + val roomKey: String = "", +) : UiState + + +sealed interface CreateGroupEffect : SideEffect { + data object NavigateToChallengeHistory : CreateGroupEffect + data class HandleException( + val throwable: Throwable, + val retryAction: () -> Unit + ) : CreateGroupEffect + +} diff --git a/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/group/create/free/CreateGroupRoute.kt b/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/group/create/free/CreateGroupRoute.kt new file mode 100644 index 00000000..8652d878 --- /dev/null +++ b/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/group/create/free/CreateGroupRoute.kt @@ -0,0 +1,213 @@ +package com.sixkids.student.group.create.free + +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.sixkids.designsystem.theme.UlbanTheme +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.model.MemberSimple +import com.sixkids.student.challenge.R +import com.sixkids.student.group.component.GroupWaiting +import com.sixkids.student.group.component.MemberIcon +import com.sixkids.student.group.component.MemberIconItem +import com.sixkids.student.group.component.MultiLayeredCircles +import com.sixkids.ui.extension.collectWithLifecycle + +@Composable +fun CreateGroupRoute( + viewModel: CreateGroupViewModel = hiltViewModel(), + navigateToChallengeHistory: () -> Unit, + handleException: (Throwable, () -> Unit) -> Unit, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.connectSse() + viewModel.createGroupMatchingRoom() + viewModel.startScan() + } + + DisposableEffect(Unit) { + onDispose { + viewModel.disconnectSse() + viewModel.stopScan() + } + } + + + viewModel.sideEffect.collectWithLifecycle { + when (it) { + is CreateGroupEffect.NavigateToChallengeHistory -> navigateToChallengeHistory() + is CreateGroupEffect.HandleException -> handleException(it.throwable, it.retryAction) + } + } + CreateGroupScreen( + uiState = uiState, + onMemberSelect = viewModel::selectMember, + onMemberRemove = viewModel::removeMember, + onGroupCreate = viewModel::createGroup + ) +} + +@Composable +fun CreateGroupScreen( + uiState: CreateGroupState = CreateGroupState(), + onMemberSelect: (MemberSimple) -> Unit = { }, + onMemberRemove: (Long) -> Unit = { }, + onGroupCreate: () -> Unit = { } +) { + Box { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.invite_friend), + style = UlbanTypography.titleSmall, + modifier = Modifier.padding(top = 32.dp) + ) + Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) { + MultiLayeredCircles(modifier = Modifier.align(Alignment.Center)) + MemberIcon( + memberIconItem = MemberIconItem( + member = uiState.leader, + isActive = true + ), modifier = Modifier.align(Alignment.Center) + ) + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + val member0 = uiState.showMembers[0] + val member1 = uiState.showMembers[1] + val member2 = uiState.showMembers[2] + val member3 = uiState.showMembers[3] + val member4 = uiState.showMembers[4] + if (member0 != null) { + androidx.compose.animation.AnimatedVisibility( + visible = true, + enter = fadeIn(), + exit = fadeOut() + ) { + Box(modifier = Modifier.offset((-100).dp, (-100).dp)) { + MemberIcon( + memberIconItem = MemberIconItem( + member = member0, + isActive = true + ), modifier = Modifier.align(Alignment.Center), + onIconClick = { onMemberSelect(member0) } + ) + } + } + } + if (member1 != null) { + androidx.compose.animation.AnimatedVisibility( + visible = true, + enter = fadeIn(), + exit = fadeOut() + ) { + Box(modifier = Modifier.offset((-135).dp, (70).dp)) { + MemberIcon( + memberIconItem = MemberIconItem( + member = member1, + isActive = true + ), modifier = Modifier.align(Alignment.Center), + onIconClick = { onMemberSelect(member1) } + ) + } + } + } + + if (member2 != null) { + androidx.compose.animation.AnimatedVisibility( + visible = true, + enter = fadeIn(), + exit = fadeOut() + ) { + Box(modifier = Modifier.offset((120).dp, (60).dp)) { + MemberIcon( + memberIconItem = MemberIconItem( + member = member2, + isActive = true + ), modifier = Modifier.align(Alignment.Center), + onIconClick = { onMemberSelect(member2) } + ) + } + } + } + + if (member3 != null) { + androidx.compose.animation.AnimatedVisibility( + visible = true, + enter = fadeIn(), + exit = fadeOut() + ) { + Box(modifier = Modifier.offset((80).dp, (-120).dp)) { + MemberIcon( + memberIconItem = MemberIconItem( + member = member3, + isActive = true + ), modifier = Modifier.align(Alignment.Center), + onIconClick = { onMemberSelect(member3) } + ) + } + } + } + + if (member4 != null) { + androidx.compose.animation.AnimatedVisibility( + visible = true, + enter = fadeIn(), + exit = fadeOut() + ) { + Box(modifier = Modifier.offset((80).dp, (-120).dp)) { + MemberIcon( + memberIconItem = MemberIconItem( + member = member4, + isActive = true + ), modifier = Modifier.align(Alignment.Center), + onIconClick = { onMemberSelect(member4) } + ) + } + } + } + } + } + + GroupWaiting( + groupSize = uiState.groupSize, + leader = uiState.leader, + memberList = uiState.selectedMembers, + waitingMemberList = uiState.waitingMembers, + onRemoveClick = { + onMemberRemove(it) + }, + onDoneClick = onGroupCreate + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun CreateGroupScreenPreview() { + UlbanTheme { + CreateGroupScreen() + } +} diff --git a/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/group/create/free/CreateGroupViewModel.kt b/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/group/create/free/CreateGroupViewModel.kt new file mode 100644 index 00000000..f6b36c58 --- /dev/null +++ b/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/group/create/free/CreateGroupViewModel.kt @@ -0,0 +1,292 @@ +package com.sixkids.student.group.create.free + +import android.Manifest +import android.util.Log +import androidx.annotation.RequiresPermission +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.sixkids.core.bluetooth.BluetoothScanner +import com.sixkids.domain.usecase.group.CreateGroupMatchingRoomUseCase +import com.sixkids.domain.usecase.group.CreateGroupUseCase +import com.sixkids.domain.usecase.group.DeportFriendUseCase +import com.sixkids.domain.usecase.group.InviteFriendUseCase +import com.sixkids.domain.usecase.user.GetATKUseCase +import com.sixkids.domain.usecase.user.GetMemberSimpleUseCase +import com.sixkids.domain.usecase.user.LoadUserInfoUseCase +import com.sixkids.model.MemberSimple +import com.sixkids.model.SseData +import com.sixkids.model.SseEventType +import com.sixkids.student.challenge.BuildConfig +import com.sixkids.ui.base.BaseViewModel +import com.sixkids.ui.extension.flatMap +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.logging.HttpLoggingInterceptor +import okhttp3.sse.EventSource +import okhttp3.sse.EventSourceListener +import okhttp3.sse.EventSources +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +private const val TAG = "D107" + +@HiltViewModel +class CreateGroupViewModel @Inject constructor( + private val bluetoothScanner: BluetoothScanner, + private val getMemberSimpleUseCase: GetMemberSimpleUseCase, + private val loadUserInfoUseCase: LoadUserInfoUseCase, + private val getATKUseCase: GetATKUseCase, + private val createGroupMatchingRoomUseCase: CreateGroupMatchingRoomUseCase, + private val inviteFriendUseCase: InviteFriendUseCase, + private val deportFriendUseCase: DeportFriendUseCase, + private val createGroupUseCase: CreateGroupUseCase, + savedStateHandle: SavedStateHandle +) : BaseViewModel(CreateGroupState()) { + + private var showUserJob: Job? = null + + private val challengeId: Long = savedStateHandle.get("challengeId") ?: 0L + + private var eventSource: EventSource? = null + + private val client = OkHttpClient.Builder() + .addInterceptor { + HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.HEADERS + }.intercept(it) + } + .connectTimeout(10, TimeUnit.MINUTES) + .readTimeout(10, TimeUnit.MINUTES) + .writeTimeout(10, TimeUnit.MINUTES) + .build() + + private val request = Request.Builder() + .url(BuildConfig.SSE_ENDPOINT) + .header("Authorization", "Bearer ${runBlocking { getATKUseCase().getOrNull() }}") + .build() + + private val eventSourceListener = object : EventSourceListener() { + override fun onOpen(eventSource: EventSource, response: Response) { + super.onOpen(eventSource, response) + Log.d(TAG, "Connection Opened") + } + + override fun onClosed(eventSource: EventSource) { + super.onClosed(eventSource) + Log.d(TAG, "Connection Closed") + } + + override fun onEvent( + eventSource: EventSource, + id: String?, + type: String?, + data: String + ) { + super.onEvent(eventSource, id, type, data) + val sseEventType: SseEventType = SseEventType.valueOf(type ?: "") + val sseData = Json.decodeFromString(data) + val url = sseData.url + val realData = sseData.data + when (sseEventType) { + SseEventType.SSE_CONNECT -> {} + SseEventType.INVITE_REQUEST -> {} + SseEventType.INVITE_RESPONSE -> { + if (url == null) return + if (realData == null) return + Log.d(TAG, "onEvent: $realData") + handelInviteResult(realData.toBoolean(), url) + } + + SseEventType.CREATE_GROUP -> Log.d(TAG, "onEvent: 그룹 생성") + SseEventType.KICK_MEMBER -> Log.d(TAG, "onEvent: 추방") + } + } + + override fun onFailure(eventSource: EventSource, t: Throwable?, response: Response?) { + super.onFailure(eventSource, t, response) + Log.d(TAG, "On Failure -: ${response?.body}") + } + } + + fun createGroupMatchingRoom() { + viewModelScope.launch { + createGroupMatchingRoomUseCase(challengeId).flatMap { matchingRoom -> + intent { + copy( + roomKey = matchingRoom.dataKey, + groupSize = matchingRoom.minCount, + ) + } + loadUserInfoUseCase() + }.onSuccess { member -> + intent { + copy( + leader = MemberSimple( + id = member.id.toLong(), + name = member.name, + photo = member.photo + ) + ) + } + }.onFailure { + postSideEffect(CreateGroupEffect.HandleException(it) { + createGroupMatchingRoom() + }) + } + } + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_SCAN) + fun startScan() { + bluetoothScanner.startScanning() + viewModelScope.launch { + bluetoothScanner.foundDevices.collect { memberIds -> + if (memberIds.isEmpty()) return@collect + val newMembers = mutableListOf() + Log.d(TAG, "startScan: $memberIds") + for (memberId in memberIds) { + getMemberSimpleUseCase(memberId).onSuccess { member -> + newMembers.add(member) + }.onFailure { + stopScan() + postSideEffect(CreateGroupEffect.HandleException(it) { + startScan() + }) + } + } + updateMembers(newMembers) + } + } + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_SCAN) + fun stopScan() { + bluetoothScanner.stopScanning() + } + + private fun updateMembers(newMembers: List) { + showUserJob?.cancel() + showUserJob = viewModelScope.launch { + val showingMembers = uiState.value.showMembers.map { it?.copy() }.toTypedArray() + newMembers.forEach { newMember -> + if(showingMembers.any { it?.id == newMember.id }) return@forEach + if(uiState.value.selectedMembers.any { it.id == newMember.id }) return@forEach + if(uiState.value.waitingMembers.any { it.id == newMember.id }) return@forEach + var added = false + while (!added) { + showingMembers.indexOfFirst { it == null }.let { index -> + if (index != -1) { + showingMembers[index] = newMember + added = true + } else { + delay(1000L) // 1초 후 다시 시도 + } + } + } + intent { + copy( + foundMembers = uiState.value.foundMembers + newMember, + showMembers = showingMembers + ) + } + } + } + } + + fun connectSse() = viewModelScope.launch { + eventSource = EventSources.createFactory(client) + .newEventSource(request, eventSourceListener) + } + + fun disconnectSse() { + eventSource?.cancel() + eventSource = null + } + + private fun handelInviteResult(isAccepted: Boolean, memberId: Long) { + if (isAccepted) { + intent { + val member = waitingMembers.first { it.id == memberId } + copy( + selectedMembers = selectedMembers.toMutableList().apply { + add(member) + } + ) + } + } else { + bluetoothScanner.removeDevice(memberId) + } + intent { + copy( + waitingMembers = waitingMembers.toMutableList().apply { + removeIf { it.id == memberId } + }, + ) + } + } + + fun selectMember(member: MemberSimple) { + viewModelScope.launch { + inviteFriendUseCase(uiState.value.roomKey, member.id).onSuccess { + val showingIdx = uiState.value.showMembers.indexOfFirst { member.id == it?.id } + uiState.value.showMembers[showingIdx] = null + intent { + copy( + foundMembers = foundMembers.toMutableList().apply { + remove(member) + }, + waitingMembers = waitingMembers.toMutableList().apply { + add(member) + } + ) + } + }.onFailure { + postSideEffect(CreateGroupEffect.HandleException(it) { + selectMember(member) + }) + } + } + } + + fun removeMember(memberId: Long) { + viewModelScope.launch { + deportFriendUseCase(uiState.value.roomKey, memberId).onSuccess { + bluetoothScanner.removeDevice(memberId) + intent { + copy( + selectedMembers = selectedMembers.toMutableList().apply { + removeIf { it.id == memberId } + }, + foundMembers = foundMembers.toMutableList().apply { + removeIf { it.id == memberId } + } + ) + } + }.onFailure { + postSideEffect(CreateGroupEffect.HandleException(it) { + removeMember(memberId) + }) + } + + } + } + + fun createGroup() { + viewModelScope.launch { + createGroupUseCase(uiState.value.roomKey).onSuccess { + postSideEffect(CreateGroupEffect.NavigateToChallengeHistory) + }.onFailure { + postSideEffect(CreateGroupEffect.HandleException(it) { + createGroup() + }) + } + } + } +} diff --git a/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/group/create/matched/MatchedCreateGroupContract.kt b/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/group/create/matched/MatchedCreateGroupContract.kt new file mode 100644 index 00000000..e23d02ce --- /dev/null +++ b/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/group/create/matched/MatchedCreateGroupContract.kt @@ -0,0 +1,28 @@ +package com.sixkids.student.group.create.matched + +import com.sixkids.model.MemberSimple +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState + +data class MatchedCreateGroupState( + val isLoading: Boolean = false, + val isScanning: Boolean = false, + val foundMembers: List = emptyList(), + val showMembers: Array = Array(5) { null }, + val selectedMembers: List = emptyList(), + val waitingMembers: List = emptyList(), + val groupSize: Int = 0, + val leader: MemberSimple = MemberSimple(), + val roomKey: String = "", +) : UiState + + +sealed interface MatchedCreateGroupEffect : SideEffect { + data object NavigateToChallengeHistory : + MatchedCreateGroupEffect + data class HandleException( + val throwable: Throwable, + val retryAction: () -> Unit + ) : MatchedCreateGroupEffect + +} diff --git a/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/group/create/matched/MatchedCreateGroupRoute.kt b/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/group/create/matched/MatchedCreateGroupRoute.kt new file mode 100644 index 00000000..ccf7226d --- /dev/null +++ b/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/group/create/matched/MatchedCreateGroupRoute.kt @@ -0,0 +1,213 @@ +package com.sixkids.student.group.create.matched + +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.sixkids.designsystem.theme.UlbanTheme +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.model.MemberSimple +import com.sixkids.student.challenge.R +import com.sixkids.student.group.component.MatchedGroupWaiting +import com.sixkids.student.group.component.MemberIcon +import com.sixkids.student.group.component.MemberIconItem +import com.sixkids.student.group.component.MultiLayeredCircles +import com.sixkids.ui.extension.collectWithLifecycle + +@Composable +fun MatchedCreateGroupRoute( + viewModel: MatchedCreateGroupViewModel = hiltViewModel(), + navigateToChallengeHistory: () -> Unit, + handleException: (Throwable, () -> Unit) -> Unit, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.connectSse() + viewModel.createGroupMatchingRoom() + viewModel.startScan() + } + + DisposableEffect(Unit) { + onDispose { + viewModel.disconnectSse() + viewModel.stopScan() + } + } + + + viewModel.sideEffect.collectWithLifecycle { + when (it) { + is MatchedCreateGroupEffect.NavigateToChallengeHistory -> navigateToChallengeHistory() + is MatchedCreateGroupEffect.HandleException -> handleException( + it.throwable, + it.retryAction + ) + } + } + MatchedCreateGroupScreen( + uiState = uiState, + onMemberSelect = viewModel::selectMember, + onMemberRemove = viewModel::removeMember, + onGroupCreate = viewModel::createGroup + ) +} + +@Composable +fun MatchedCreateGroupScreen( + uiState: MatchedCreateGroupState = MatchedCreateGroupState(), + onMemberSelect: (MemberSimple) -> Unit = { }, + onMemberRemove: (Long) -> Unit = { }, + onGroupCreate: () -> Unit = { } +) { + Box { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.invite_friend), + style = UlbanTypography.titleSmall, + modifier = Modifier.padding(top = 32.dp) + ) + Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) { + MultiLayeredCircles(modifier = Modifier.align(Alignment.Center)) + MemberIcon( + memberIconItem = MemberIconItem( + member = uiState.leader, + isActive = true + ), modifier = Modifier.align(Alignment.Center) + ) + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + val member0 = uiState.showMembers[0] + val member1 = uiState.showMembers[1] + val member2 = uiState.showMembers[2] + val member3 = uiState.showMembers[3] + val member4 = uiState.showMembers[4] + if (member0 != null) { + androidx.compose.animation.AnimatedVisibility( + visible = true, + enter = fadeIn(), + exit = fadeOut() + ) { + Box(modifier = Modifier.offset((-100).dp, (-100).dp)) { + MemberIcon( + memberIconItem = MemberIconItem( + member = member0, + isActive = true + ), modifier = Modifier.align(Alignment.Center), + onIconClick = { onMemberSelect(member0) } + ) + } + } + } + if (member1 != null) { + androidx.compose.animation.AnimatedVisibility( + visible = true, + enter = fadeIn(), + exit = fadeOut() + ) { + Box(modifier = Modifier.offset((-135).dp, (70).dp)) { + MemberIcon( + memberIconItem = MemberIconItem( + member = member1, + isActive = true + ), modifier = Modifier.align(Alignment.Center), + onIconClick = { onMemberSelect(member1) } + ) + } + } + } + + if (member2 != null) { + androidx.compose.animation.AnimatedVisibility( + visible = true, + enter = fadeIn(), + exit = fadeOut() + ) { + Box(modifier = Modifier.offset((120).dp, (60).dp)) { + MemberIcon( + memberIconItem = MemberIconItem( + member = member2, + isActive = true + ), modifier = Modifier.align(Alignment.Center), + onIconClick = { onMemberSelect(member2) } + ) + } + } + } + + if (member3 != null) { + androidx.compose.animation.AnimatedVisibility( + visible = true, + enter = fadeIn(), + exit = fadeOut() + ) { + Box(modifier = Modifier.offset((80).dp, (-120).dp)) { + MemberIcon( + memberIconItem = MemberIconItem( + member = member3, + isActive = true + ), modifier = Modifier.align(Alignment.Center), + onIconClick = { onMemberSelect(member3) } + ) + } + } + } + + if (member4 != null) { + androidx.compose.animation.AnimatedVisibility( + visible = true, + enter = fadeIn(), + exit = fadeOut() + ) { + Box(modifier = Modifier.offset((80).dp, (-120).dp)) { + MemberIcon( + memberIconItem = MemberIconItem( + member = member4, + isActive = true + ), modifier = Modifier.align(Alignment.Center), + onIconClick = { onMemberSelect(member4) } + ) + } + } + } + } + } + + MatchedGroupWaiting( + groupSize = uiState.groupSize, + leader = uiState.leader, + memberList = uiState.selectedMembers, + waitingMemberList = uiState.waitingMembers, + onDoneClick = onGroupCreate + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun CreateGroupScreenPreview() { + UlbanTheme { + MatchedCreateGroupScreen() + } +} diff --git a/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/group/create/matched/MatchedCreateGroupViewModel.kt b/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/group/create/matched/MatchedCreateGroupViewModel.kt new file mode 100644 index 00000000..1a02c81c --- /dev/null +++ b/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/group/create/matched/MatchedCreateGroupViewModel.kt @@ -0,0 +1,291 @@ +package com.sixkids.student.group.create.matched + +import android.Manifest +import android.util.Log +import androidx.annotation.RequiresPermission +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.sixkids.core.bluetooth.BluetoothScanner +import com.sixkids.domain.usecase.group.CreateGroupMatchingRoomUseCase +import com.sixkids.domain.usecase.group.CreateGroupUseCase +import com.sixkids.domain.usecase.group.DeportFriendUseCase +import com.sixkids.domain.usecase.group.InviteFriendUseCase +import com.sixkids.domain.usecase.user.GetATKUseCase +import com.sixkids.domain.usecase.user.GetMemberSimpleUseCase +import com.sixkids.domain.usecase.user.LoadUserInfoUseCase +import com.sixkids.model.MemberSimple +import com.sixkids.model.SseData +import com.sixkids.model.SseEventType +import com.sixkids.student.challenge.BuildConfig +import com.sixkids.student.navigation.GroupRoute +import com.sixkids.ui.base.BaseViewModel +import com.sixkids.ui.extension.flatMap +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.logging.HttpLoggingInterceptor +import okhttp3.sse.EventSource +import okhttp3.sse.EventSourceListener +import okhttp3.sse.EventSources +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +private const val TAG = "D107" + +@HiltViewModel +class MatchedCreateGroupViewModel @Inject constructor( + private val bluetoothScanner: BluetoothScanner, + private val getMemberSimpleUseCase: GetMemberSimpleUseCase, + private val loadUserInfoUseCase: LoadUserInfoUseCase, + private val getATKUseCase: GetATKUseCase, + private val createGroupMatchingRoomUseCase: CreateGroupMatchingRoomUseCase, + private val inviteFriendUseCase: InviteFriendUseCase, + private val deportFriendUseCase: DeportFriendUseCase, + private val createGroupUseCase: CreateGroupUseCase, + savedStateHandle: SavedStateHandle +) : BaseViewModel(MatchedCreateGroupState()) { + + private var showUserJob: Job? = null + + private val challengeId: Long = savedStateHandle.get("challengeId") ?: 0L + + private val members: List = + Json.decodeFromString((savedStateHandle.get(GroupRoute.MEMBERS_NAME) ?: "")) + + private var eventSource: EventSource? = null + + private val client = OkHttpClient.Builder() + .addInterceptor { + HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.HEADERS + }.intercept(it) + } + .connectTimeout(10, TimeUnit.MINUTES) + .readTimeout(10, TimeUnit.MINUTES) + .writeTimeout(10, TimeUnit.MINUTES) + .build() + + private val request = Request.Builder() + .url(BuildConfig.SSE_ENDPOINT) + .header("Authorization", "Bearer ${runBlocking { getATKUseCase().getOrNull() }}") + .build() + + private val eventSourceListener = object : EventSourceListener() { + override fun onOpen(eventSource: EventSource, response: Response) { + super.onOpen(eventSource, response) + Log.d(TAG, "Connection Opened") + } + + override fun onClosed(eventSource: EventSource) { + super.onClosed(eventSource) + Log.d(TAG, "Connection Closed") + } + + override fun onEvent( + eventSource: EventSource, + id: String?, + type: String?, + data: String + ) { + super.onEvent(eventSource, id, type, data) + val sseEventType: SseEventType = SseEventType.valueOf(type ?: "") + val sseData = Json.decodeFromString(data) + val url = sseData.url + val realData = sseData.data + when (sseEventType) { + SseEventType.SSE_CONNECT -> {} + SseEventType.INVITE_REQUEST -> {} + SseEventType.INVITE_RESPONSE -> { + if (url == null) return + if (realData == null) return + Log.d(TAG, "onEvent: $realData") + handelInviteResult(realData.toBoolean(), url) + } + + SseEventType.CREATE_GROUP -> Log.d(TAG, "onEvent: 그룹 생성") + SseEventType.KICK_MEMBER -> Log.d(TAG, "onEvent: 추방") + } + } + + override fun onFailure(eventSource: EventSource, t: Throwable?, response: Response?) { + super.onFailure(eventSource, t, response) + Log.d(TAG, "On Failure -: ${response?.body}") + } + } + + fun createGroupMatchingRoom() { + viewModelScope.launch { + createGroupMatchingRoomUseCase(challengeId).flatMap { matchingRoom -> + intent { + copy( + roomKey = matchingRoom.dataKey, + groupSize = members.size, + ) + } + loadUserInfoUseCase() + }.onSuccess { member -> + intent { + copy( + leader = MemberSimple( + id = member.id.toLong(), + name = member.name, + photo = member.photo + ), + waitingMembers = members.filter { it.id != member.id.toLong()} + ) + } + }.onFailure { + postSideEffect(MatchedCreateGroupEffect.HandleException(it) { + createGroupMatchingRoom() + }) + } + } + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_SCAN) + fun startScan() { + bluetoothScanner.startScanning() + viewModelScope.launch { + bluetoothScanner.foundDevices.collect { memberIds -> + if (memberIds.isEmpty()) return@collect + val newMembers = mutableListOf() + Log.d(TAG, "startScan: $memberIds") + for (memberId in memberIds) { + if(uiState.value.waitingMembers.all { it.id != memberId }) continue + getMemberSimpleUseCase(memberId).onSuccess { member -> + newMembers.add(member) + }.onFailure { + stopScan() + postSideEffect(MatchedCreateGroupEffect.HandleException(it) { + startScan() + }) + } + } + updateMembers(newMembers) + } + } + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_SCAN) + fun stopScan() { + bluetoothScanner.stopScanning() + } + + private fun updateMembers(newMembers: List) { + showUserJob?.cancel() + showUserJob = viewModelScope.launch { + val showingMembers = uiState.value.showMembers.map { it?.copy() }.toTypedArray() + newMembers.forEach { newMember -> + if(showingMembers.any { it?.id == newMember.id }) return@forEach + if(uiState.value.selectedMembers.any { it.id == newMember.id }) return@forEach + var added = false + while (!added) { + showingMembers.indexOfFirst { it == null }.let { index -> + if (index != -1) { + showingMembers[index] = newMember + added = true + } else { + delay(1000L) // 1초 후 다시 시도 + } + } + } + intent { + copy( + foundMembers = uiState.value.foundMembers + newMember, + showMembers = showingMembers + ) + } + } + } + } + + fun connectSse() = viewModelScope.launch { + eventSource = EventSources.createFactory(client) + .newEventSource(request, eventSourceListener) + } + + fun disconnectSse() { + eventSource?.cancel() + eventSource = null + } + + private fun handelInviteResult(isAccepted: Boolean, memberId: Long) { + if (isAccepted) { + intent { + val member = waitingMembers.first { it.id == memberId } + copy( + selectedMembers = selectedMembers.toMutableList().apply { + add(member) + }, + waitingMembers = waitingMembers.toMutableList().apply { + remove(member) + } + ) + } + } else { + bluetoothScanner.removeDevice(memberId) + } + + } + + fun selectMember(member: MemberSimple) { + viewModelScope.launch { + inviteFriendUseCase(uiState.value.roomKey, member.id).onSuccess { + val showingIdx = uiState.value.showMembers.indexOfFirst { member.id == it?.id } + uiState.value.showMembers[showingIdx] = null + intent { + copy( + foundMembers = foundMembers.toMutableList().apply { + remove(member) + }, + ) + } + }.onFailure { + postSideEffect(MatchedCreateGroupEffect.HandleException(it) { + selectMember(member) + }) + } + } + } + + fun removeMember(memberId: Long) { + viewModelScope.launch { + deportFriendUseCase(uiState.value.roomKey, memberId).onSuccess { + bluetoothScanner.removeDevice(memberId) + intent { + copy( + selectedMembers = selectedMembers.toMutableList().apply { + removeIf { it.id == memberId } + }, + foundMembers = foundMembers.toMutableList().apply { + removeIf { it.id == memberId } + } + ) + } + }.onFailure { + postSideEffect(MatchedCreateGroupEffect.HandleException(it) { + removeMember(memberId) + }) + } + + } + } + + fun createGroup() { + viewModelScope.launch { + createGroupUseCase(uiState.value.roomKey).onSuccess { + postSideEffect(MatchedCreateGroupEffect.NavigateToChallengeHistory) + }.onFailure { + postSideEffect(MatchedCreateGroupEffect.HandleException(it) { + createGroup() + }) + } + } + } +} diff --git a/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/group/join/JoinGroupContract.kt b/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/group/join/JoinGroupContract.kt new file mode 100644 index 00000000..62de75b0 --- /dev/null +++ b/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/group/join/JoinGroupContract.kt @@ -0,0 +1,23 @@ +package com.sixkids.student.group.join + +import com.sixkids.model.MemberSimple +import com.sixkids.student.group.component.MemberIconItem +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState + + +data class JoinGroupState( + val isLoading: Boolean = false, + val leader: MemberIconItem = MemberIconItem(), + val member: MemberSimple = MemberSimple(), + val roomKey: String = "", + val isJoinedGroup: Boolean = false, +) : UiState + +sealed interface JoinGroupEffect : SideEffect { + data class HandleException(val it: Throwable, val retryAction: () -> Unit) : JoinGroupEffect + data object ReceiveInviteRequest : JoinGroupEffect + data object CloseInviteDialog : JoinGroupEffect + data object NavigateToChallengeHistory : JoinGroupEffect + +} diff --git a/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/group/join/JoinGroupScreen.kt b/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/group/join/JoinGroupScreen.kt new file mode 100644 index 00000000..b4f9d27c --- /dev/null +++ b/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/group/join/JoinGroupScreen.kt @@ -0,0 +1,133 @@ +package com.sixkids.student.group.join + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.model.MemberSimple +import com.sixkids.student.challenge.R +import com.sixkids.student.group.component.InviteDialog +import com.sixkids.student.group.component.MemberIcon +import com.sixkids.student.group.component.MemberIconItem +import com.sixkids.student.group.component.MultiLayeredCircles +import com.sixkids.ui.extension.collectWithLifecycle + + +@Composable +fun JoinGroupRoute( + viewModel: JoinGroupViewModel = hiltViewModel(), + handleException: (Throwable, () -> Unit) -> Unit = { _, _ -> }, + navigateToChallengeHistory: () -> Unit +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + var isShowInviteDialog by remember { mutableStateOf(false) } + + + LaunchedEffect(Unit) { + viewModel.connectSse() + viewModel.loadUserInfo() + viewModel.startAdvertise() + } + + DisposableEffect(Unit) { + onDispose { + viewModel.disconnectSse() + viewModel.stopAdvertise() + } + } + + viewModel.sideEffect.collectWithLifecycle { sideEffect -> + when (sideEffect) { + is JoinGroupEffect.HandleException -> handleException( + sideEffect.it, + sideEffect.retryAction + ) + + is JoinGroupEffect.ReceiveInviteRequest -> { + isShowInviteDialog = true + } + + JoinGroupEffect.CloseInviteDialog -> { + isShowInviteDialog = false + } + + JoinGroupEffect.NavigateToChallengeHistory -> navigateToChallengeHistory() + + } + + } + JoinGroupScreen(uiState) + + if (isShowInviteDialog) { + InviteDialog( + leader = uiState.leader, + onConfirmClick = { + viewModel.answerInvite(true) + }, + onCancelClick = { + viewModel.answerInvite(false) + } + ) + } +} + +@Composable +fun JoinGroupScreen( + uiState: JoinGroupState = JoinGroupState() +) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = if(uiState.isJoinedGroup) stringResource(R.string.waiting_group_start) else stringResource(R.string.wait_group_invite), + style = UlbanTypography.titleSmall, + modifier = Modifier.padding(top = 32.dp) + ) + Box(modifier = Modifier.weight(1f)) { + MultiLayeredCircles(modifier = Modifier.align(Alignment.Center)) + MemberIcon( + memberIconItem = MemberIconItem( + member = uiState.member, + isActive = true + ), modifier = Modifier.align(Alignment.Center) + ) + } + } + + +} + +@Preview(showBackground = true) +@Composable +fun JoinGroupScreenPreview() { + JoinGroupScreen( + uiState = JoinGroupState( + isLoading = false, + member = MemberSimple( + id = 1, + name = "김철수", + photo = "https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png" + ) + ) + ) +} diff --git a/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/group/join/JoinGroupViewModel.kt b/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/group/join/JoinGroupViewModel.kt new file mode 100644 index 00000000..2bd2832d --- /dev/null +++ b/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/group/join/JoinGroupViewModel.kt @@ -0,0 +1,185 @@ +package com.sixkids.student.group.join + +import android.util.Log +import androidx.lifecycle.viewModelScope +import com.sixkids.core.bluetooth.BluetoothServer +import com.sixkids.domain.usecase.group.JoinGroupUseCase +import com.sixkids.domain.usecase.user.GetATKUseCase +import com.sixkids.domain.usecase.user.GetMemberSimpleUseCase +import com.sixkids.domain.usecase.user.LoadUserInfoUseCase +import com.sixkids.model.MemberSimple +import com.sixkids.model.SseData +import com.sixkids.model.SseEventType +import com.sixkids.student.challenge.BuildConfig +import com.sixkids.student.group.component.MemberIconItem +import com.sixkids.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.logging.HttpLoggingInterceptor +import okhttp3.sse.EventSource +import okhttp3.sse.EventSourceListener +import okhttp3.sse.EventSources +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +private const val TAG = "D107" + +@HiltViewModel +class JoinGroupViewModel @Inject constructor( + private val bluetoothServer: BluetoothServer, + private val loadUserInfoUseCase: LoadUserInfoUseCase, + private val getATKUseCase: GetATKUseCase, + private val getMemberSimpleUseCase: GetMemberSimpleUseCase, + private val joinGroupUseCase: JoinGroupUseCase +) : BaseViewModel(JoinGroupState()) { + + private var eventSource: EventSource? = null + + private val client = OkHttpClient.Builder() + .addInterceptor { + HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.HEADERS + }.intercept(it) + } + .connectTimeout(10, TimeUnit.MINUTES) + .readTimeout(10, TimeUnit.MINUTES) + .writeTimeout(10, TimeUnit.MINUTES) + .build() + + private val request = Request.Builder() + .url(BuildConfig.SSE_ENDPOINT) + .header("Authorization", "Bearer ${runBlocking { getATKUseCase().getOrNull() }}") + .build() + + private val eventSourceListener = object : EventSourceListener() { + override fun onOpen(eventSource: EventSource, response: Response) { + super.onOpen(eventSource, response) + Log.d(TAG, "Connection Opened") + } + + override fun onClosed(eventSource: EventSource) { + super.onClosed(eventSource) + Log.d(TAG, "Connection Closed") + } + + override fun onEvent( + eventSource: EventSource, + id: String?, + type: String?, + data: String + ) { + super.onEvent(eventSource, id, type, data) + val sseEventType: SseEventType = SseEventType.valueOf(type ?: "") + val sseData = Json.decodeFromString(data) + val url = sseData.url + val roomKey = sseData.data + when (sseEventType) { + SseEventType.SSE_CONNECT -> {} + SseEventType.INVITE_REQUEST -> { + if (url == null) return + if (roomKey == null) return + viewModelScope.launch { + getMemberSimpleUseCase(url).onSuccess { + intent { + copy( + leader = MemberIconItem( + member = it, + showX = false, + isActive = true + ), + roomKey = roomKey, + isJoinedGroup = true + ) + } + postSideEffect(JoinGroupEffect.ReceiveInviteRequest) + }.onFailure { + postSideEffect(JoinGroupEffect.HandleException(it, ::loadUserInfo)) + } + } + } + + SseEventType.INVITE_RESPONSE -> Log.d(TAG, "onEvent: 초대 응답") + SseEventType.KICK_MEMBER -> { + intent { + copy( + isJoinedGroup = false + ) + } + startAdvertise() + } + SseEventType.CREATE_GROUP -> { + postSideEffect(JoinGroupEffect.NavigateToChallengeHistory) + } + } + } + + override fun onFailure(eventSource: EventSource, t: Throwable?, response: Response?) { + super.onFailure(eventSource, t, response) + Log.d(TAG, "On Failure -: ${response?.body} ${t?.message}") + } + } + + fun connectSse() = viewModelScope.launch { + eventSource = EventSources.createFactory(client) + .newEventSource(request, eventSourceListener) + } + + fun disconnectSse() { + eventSource?.cancel() + eventSource = null + } + + fun loadUserInfo() { + viewModelScope.launch { + loadUserInfoUseCase().onSuccess { + intent { + copy( + member = MemberSimple( + id = it.id.toLong(), + name = it.name, + photo = it.photo + ) + ) + } + }.onFailure { + postSideEffect(JoinGroupEffect.HandleException(it, ::loadUserInfo)) + } + } + } + + fun startAdvertise() { + viewModelScope.launch { + bluetoothServer.startAdvertising(uiState.value.member.id) + } + } + + fun stopAdvertise() { + bluetoothServer.stopAdvertising() + } + + fun answerInvite(joinStatus: Boolean) { + viewModelScope.launch { + joinGroupUseCase(uiState.value.roomKey, joinStatus).onSuccess { + intent { + copy(isJoinedGroup = joinStatus) + } + if(joinStatus){ + stopAdvertise() + } + closeDialog() + }.onFailure { + postSideEffect(JoinGroupEffect.HandleException(it){ + answerInvite(joinStatus) + }) + } + } + } + + private fun closeDialog() = postSideEffect(JoinGroupEffect.CloseInviteDialog) + +} diff --git a/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/navigation/ChallengeNavigation.kt b/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/navigation/ChallengeNavigation.kt new file mode 100644 index 00000000..c4b5299c --- /dev/null +++ b/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/navigation/ChallengeNavigation.kt @@ -0,0 +1,47 @@ +package com.sixkids.student.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable +import com.sixkids.model.GroupType +import com.sixkids.model.MemberSimple +import com.sixkids.student.challeng.history.ChallengeRoute + +fun NavController.navigateStudentChallengeHistory(navOptions: NavOptions) { + navigate(ChallengeRoute.defaultRoute, navOptions) +} + +fun NavController.navigatePopupToStudentChallengeHistory() { + navigate(ChallengeRoute.defaultRoute) { + popUpTo(ChallengeRoute.defaultRoute) { + inclusive = true + } + } +} + + +fun NavGraphBuilder.studentChallengeNavGraph( + navigateChallengeDetail: (Long, Long?) -> Unit, + navigateToCreateGroup: (Long, GroupType) -> Unit, + navigateToMatchedGroupCreate: (Long, List) -> Unit, + navigateToJoinGroup: (Long) -> Unit, + handleException: (Throwable, () -> Unit) -> Unit +) { + composable(route = ChallengeRoute.defaultRoute) { + ChallengeRoute( + navigateToDetail = { challengeId, groupId -> + navigateChallengeDetail(challengeId, groupId) + }, + navigateToCreateGroup = navigateToCreateGroup, + navigateToMatchedGroupCreate = navigateToMatchedGroupCreate, + navigateToJoinGroup = navigateToJoinGroup, + handleException = handleException + ) + } + +} + +object ChallengeRoute { + const val defaultRoute = "student/challenge-history" +} diff --git a/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/navigation/GroupNavigation.kt b/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/navigation/GroupNavigation.kt new file mode 100644 index 00000000..d0d850ce --- /dev/null +++ b/android/feature/student/challenge/src/main/kotlin/com/sixkids/student/navigation/GroupNavigation.kt @@ -0,0 +1,89 @@ +package com.sixkids.student.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavType +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import com.sixkids.model.GroupType +import com.sixkids.model.MemberSimple +import com.sixkids.student.group.create.free.CreateGroupRoute +import com.sixkids.student.group.create.matched.MatchedCreateGroupRoute +import com.sixkids.student.group.join.JoinGroupRoute +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.encodeToJsonElement + +fun NavController.navigateStudentGroupCreate( + challengeId: Long, + groupType: GroupType +) { + navigate(GroupRoute.createGroupRoute(challengeId, groupType)) +} + +fun NavController.navigateStudentMatchedGroupCreate( + challengeId: Long, + members: List +) { + navigate(GroupRoute.matchedGroupCreateRoute(challengeId, members)) +} + + +fun NavController.navigateStudentGroupJoin() { + navigate(GroupRoute.joinGroupRoute) +} + +fun NavGraphBuilder.studentGroupNavGraph( + navigateToChallengeHistory: () -> Unit, + handleException: (Throwable, () -> Unit) -> Unit +) { + composable( + route = GroupRoute.creatGroupRoute, + arguments = listOf(navArgument(GroupRoute.CHALLENGE_ID_NAME) { type = NavType.LongType }) + ) { + CreateGroupRoute( + navigateToChallengeHistory = navigateToChallengeHistory, + handleException = handleException + ) + } + composable(route = GroupRoute.joinGroupRoute) { + JoinGroupRoute( + navigateToChallengeHistory = navigateToChallengeHistory, + handleException = handleException + ) + } + composable( + route = GroupRoute.matchedGroupCreateRoute, + arguments = listOf( + navArgument(GroupRoute.CHALLENGE_ID_NAME) { type = NavType.LongType }, + navArgument(GroupRoute.MEMBERS_NAME) { type = NavType.StringType } + ) + ) { + MatchedCreateGroupRoute( + navigateToChallengeHistory = navigateToChallengeHistory, + handleException = handleException + ) + + } +} + +object GroupRoute { + + const val CHALLENGE_ID_NAME = "challengeId" + const val GROUP_TYPE_NAME = "groupType" + const val MEMBERS_NAME = "members" + + const val creatGroupRoute = + "student/group/create?challengeId={$CHALLENGE_ID_NAME}?groupType={$GROUP_TYPE_NAME}" + const val matchedGroupCreateRoute = + "student/group/matched-create?challengeId={$CHALLENGE_ID_NAME}?members={$MEMBERS_NAME}" + const val joinGroupRoute = "student/group/join" + + fun createGroupRoute(challengeId: Long, groupType: GroupType): String { + return "student/group/create?challengeId=$challengeId?groupType=$groupType" + } + + fun matchedGroupCreateRoute(challengeId: Long, members: List): String { + val jsonMembers = Json.encodeToJsonElement(members) + return "student/group/matched-create?challengeId=$challengeId?members=$jsonMembers" + } +} diff --git a/android/feature/student/challenge/src/main/res/values/strings.xml b/android/feature/student/challenge/src/main/res/values/strings.xml new file mode 100644 index 00000000..168ed8c2 --- /dev/null +++ b/android/feature/student/challenge/src/main/res/values/strings.xml @@ -0,0 +1,22 @@ + + + 함께달리기 + 진행 중인\n함께 달리기가\n없습니다. + 지금까지 %d번 함께 달리기를 진행했어요 + 기록이 없어요 + 취소 + 그룹\n만들기 + 그룹\n참여하기 + 완료 + 근처에 있는 친구를 초대해 보세요 + %d명의 친구들을 더 모아보세요 + 아직 %d명의 친구가 모이지 않았어요 + + 그룹을 만들 수 있어요 + 친구의 초대를 기다리고 있어요 + 팀원 + 종료 + 자유롭게 팀을 구성해봐요 + 친구가 나를 초대했어요! + 그룹이 시작되기를 기다리고 있어요. + diff --git a/android/feature/student/home/build.gradle.kts b/android/feature/student/home/build.gradle.kts index 3fe4cf9f..38dc2203 100644 --- a/android/feature/student/home/build.gradle.kts +++ b/android/feature/student/home/build.gradle.kts @@ -1,10 +1,43 @@ +import java.util.Properties + plugins { alias(libs.plugins.sixkids.android.feature.compose) + alias(libs.plugins.kotlin.serialization) } android { namespace = "com.sixkids.student.home" + + defaultConfig { + val localProperties = Properties() + val localPropertiesFile = rootProject.file("local.properties") + if (localPropertiesFile.exists()) { + localProperties.load(localPropertiesFile.inputStream()) + } + + val stompUrl = localProperties.getProperty("STOMP_ENDPOINT") ?: "" + + buildConfigField("String", "STOMP_ENDPOINT", "\"${stompUrl}\"") + } + + buildFeatures { + buildConfig = true + } } dependencies { + implementation(libs.okhttp) + implementation(libs.okhttp.logginginterceptor) + + implementation(libs.moshi.kotlin) + implementation(libs.moshi.converter) + implementation(libs.krossbow.stomp. core) + implementation(libs.krossbow.websocket.okhttp) + implementation(libs.krossbow.stomp.moshi) + + implementation(libs.bundles.paging) + + implementation(libs.kotlinx.serialization.json) + implementation(projects.core.nfc) + } diff --git a/android/feature/student/home/src/main/java/com/sixkids/student/home/announce/announcedetail/StudentAnnounceDetailContract.kt b/android/feature/student/home/src/main/java/com/sixkids/student/home/announce/announcedetail/StudentAnnounceDetailContract.kt new file mode 100644 index 00000000..78dd38a6 --- /dev/null +++ b/android/feature/student/home/src/main/java/com/sixkids/student/home/announce/announcedetail/StudentAnnounceDetailContract.kt @@ -0,0 +1,17 @@ +package com.sixkids.student.home.announce.announcedetail + +import com.sixkids.model.PostDetail +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState + +sealed interface StudentAnnounceDetailEffect : SideEffect { + data object RefreshAnnounceDetail : StudentAnnounceDetailEffect + data class OnShowSnackbar(val message: String) : StudentAnnounceDetailEffect +} + +data class StudentAnnounceDetailState( + val isLoading: Boolean = false, + val postDetail: PostDetail = PostDetail(), + val commentText: String = "", + val selectedCommentId: Long? = null, +) : UiState \ No newline at end of file diff --git a/android/feature/student/home/src/main/java/com/sixkids/student/home/announce/announcedetail/StudentAnnounceDetailScreen.kt b/android/feature/student/home/src/main/java/com/sixkids/student/home/announce/announcedetail/StudentAnnounceDetailScreen.kt new file mode 100644 index 00000000..fdcdf7a1 --- /dev/null +++ b/android/feature/student/home/src/main/java/com/sixkids/student/home/announce/announcedetail/StudentAnnounceDetailScreen.kt @@ -0,0 +1,199 @@ +package com.sixkids.student.home.announce.announcedetail + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import coil.compose.AsyncImage +import com.sixkids.designsystem.R +import com.sixkids.designsystem.component.screen.LoadingScreen +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.student.home.announce.component.CommentItem +import com.sixkids.student.home.announce.component.CommentTextField +import com.sixkids.student.home.announce.component.PostWriterInfo +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.util.formatToMonthDayTime + +@Composable +fun StudentAnnounceDetailRoute( + viewModel: StudentAnnounceDetailViewModel = hiltViewModel(), + padding: PaddingValues, + onShowSnackBar: (SnackbarToken) -> Unit +) { + val uiState by viewModel.uiState.collectAsState() + // 키보드 숨기기 + var keyboardHideState by remember { mutableStateOf(false) } + if (keyboardHideState) { + LocalSoftwareKeyboardController.current?.hide() + keyboardHideState = false + } + + LaunchedEffect(Unit) { + viewModel.getAnnounceDetail() + } + + LaunchedEffect(viewModel.sideEffect) { + viewModel.sideEffect.collect { sideEffect -> + when (sideEffect) { + StudentAnnounceDetailEffect.RefreshAnnounceDetail -> { + keyboardHideState = true + viewModel.getAnnounceDetail() + } + + is StudentAnnounceDetailEffect.OnShowSnackbar -> { + onShowSnackBar(SnackbarToken(message = sideEffect.message)) + } + } + } + } + + Box(modifier = Modifier.padding(padding)) { + StudentAnnounceDetailScreen( + studentAnnounceDetailState = uiState, + onCommentTextChanged = viewModel::onCommentTextChanged, + onClickComment = viewModel::onSelectedCommentId, + onClickSubmitComment = viewModel::onNewComment, + ) + } +} + +@Composable +fun StudentAnnounceDetailScreen( + modifier: Modifier = Modifier, + studentAnnounceDetailState: StudentAnnounceDetailState = StudentAnnounceDetailState(), + onCommentTextChanged: (String) -> Unit = {}, + onClickComment: (Long) -> Unit = {}, + onClickSubmitComment: () -> Unit = {}, +) { + + val scrollState = rememberScrollState() + + BackHandler( + enabled = studentAnnounceDetailState.selectedCommentId != null, + onBack = { onClickComment(studentAnnounceDetailState.selectedCommentId ?: 0) } + ) + + Box { + Column { + Column( + modifier = modifier + .weight(1f) + .padding(20.dp) + .verticalScroll(scrollState), + ) { + // 작성자 정보 + Row( + verticalAlignment = Alignment.CenterVertically + ) { + PostWriterInfo( + height = 60.dp, + writer = studentAnnounceDetailState.postDetail.writeMember.name, + dateString = studentAnnounceDetailState.postDetail.createTime.formatToMonthDayTime(), + writerImageUrl = studentAnnounceDetailState.postDetail.writeMember.photo + ) + Spacer(modifier = Modifier.weight(1f)) + } + + Spacer(modifier = Modifier.height(10.dp)) + Text( + text = studentAnnounceDetailState.postDetail.title, + style = UlbanTypography.titleLarge + ) + Spacer(modifier = Modifier.height(10.dp)) + // 이미지 + if (studentAnnounceDetailState.postDetail.imageUri.isNotEmpty()) { + AsyncImage( + model = studentAnnounceDetailState.postDetail.imageUri, + contentDescription = null, + contentScale = ContentScale.Crop, + ) + } + Spacer(modifier = Modifier.height(10.dp)) + Text( + text = studentAnnounceDetailState.postDetail.content, + style = UlbanTypography.bodyLarge + ) + Spacer(modifier = Modifier.height(10.dp)) + HorizontalDivider( + thickness = 2.dp, + color = Color.Black + ) + // 댓글 목록 + for (comment in studentAnnounceDetailState.postDetail.comments) { + CommentItem( + selected = studentAnnounceDetailState.selectedCommentId == comment.id, + writer = comment.member.name, + dateString = comment.createTime.formatToMonthDayTime(), + writerImageUrl = comment.member.photo, + commentString = comment.content, + recommentOnclick = { + onClickComment(comment.id) + } + ) + // 대댓글 목록 + for (recomment in comment.recomments) { + Row { + Icon( + modifier = Modifier.padding(4.dp), + imageVector = ImageVector.vectorResource(id = R.drawable.ic_recomment), + contentDescription = null + ) + CommentItem( + writer = recomment.member.name, + dateString = recomment.createTime.formatToMonthDayTime(), + writerImageUrl = recomment.member.photo, + commentString = recomment.content, + isRecomment = true + ) + } + + } + } + } + CommentTextField( + msg = studentAnnounceDetailState.commentText, + onTextIuputChange = onCommentTextChanged, + onSendClick = { onClickSubmitComment() } + ) + } + + + if (studentAnnounceDetailState.isLoading) { + LoadingScreen() + } + } +} + +@Preview(showBackground = true) +@Composable +fun StudentAnnounceDetailScreenPreview() { + StudentAnnounceDetailScreen() +} \ No newline at end of file diff --git a/android/feature/student/home/src/main/java/com/sixkids/student/home/announce/announcedetail/StudentAnnounceDetailViewModel.kt b/android/feature/student/home/src/main/java/com/sixkids/student/home/announce/announcedetail/StudentAnnounceDetailViewModel.kt new file mode 100644 index 00000000..8985498c --- /dev/null +++ b/android/feature/student/home/src/main/java/com/sixkids/student/home/announce/announcedetail/StudentAnnounceDetailViewModel.kt @@ -0,0 +1,99 @@ +package com.sixkids.student.home.announce.announcedetail + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.sixkids.domain.usecase.comment.DeleteCommentUseCase +import com.sixkids.domain.usecase.comment.NewCommentUseCase +import com.sixkids.domain.usecase.comment.NewRecommentUseCase +import com.sixkids.domain.usecase.comment.ReportCommentUseCase +import com.sixkids.domain.usecase.comment.UpdateCommentUsecase +import com.sixkids.domain.usecase.post.DeletePostUseCase +import com.sixkids.domain.usecase.post.GetPostDetailUseCase +import com.sixkids.domain.usecase.post.UpdatePostUseCase +import com.sixkids.student.home.navigation.StudentHomeRoute +import com.sixkids.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class StudentAnnounceDetailViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val getPostDetailUseCase: GetPostDetailUseCase, + private val deleteCommentUseCase: DeleteCommentUseCase, + private val updateCommentUsecase: UpdateCommentUsecase, + private val newCommentUseCase: NewCommentUseCase, + private val newRecommentUseCase: NewRecommentUseCase, + private val reportCommentUseCase: ReportCommentUseCase +): BaseViewModel(StudentAnnounceDetailState()){ + + private val postId: Long = savedStateHandle.get(StudentHomeRoute.announceDetailARG)!! + + fun onCommentTextChanged(commentText: String) = intent { copy(commentText = commentText) } + fun onSelectedCommentId(commentId: Long?) = intent { + if (currentState.selectedCommentId == commentId) { + copy(selectedCommentId = null) + } else { + copy(selectedCommentId = commentId) + } + } + + fun getAnnounceDetail() { + viewModelScope.launch { + intent { copy(isLoading = true) } + getPostDetailUseCase(postId).onSuccess { + intent { copy(postDetail = it) } + }.onFailure { + postSideEffect(StudentAnnounceDetailEffect.OnShowSnackbar(it.message ?: "게시글을 불러오지 못했어요")) + } + intent { copy(isLoading = false) } + } + } + + fun onNewComment() { + if (currentState.commentText.isBlank()) { + postSideEffect(StudentAnnounceDetailEffect.OnShowSnackbar("댓글을 입력해주세요")) + } else { + if (currentState.selectedCommentId == null) { + viewModelScope.launch { + intent { copy(isLoading = true) } + newCommentUseCase( + postId = postId, + content = currentState.commentText, + ).onSuccess { + postSideEffect(StudentAnnounceDetailEffect.OnShowSnackbar("댓글이 작성되었습니다")) + intent { copy(commentText = "", selectedCommentId = null) } + getAnnounceDetail() + }.onFailure { + postSideEffect( + StudentAnnounceDetailEffect.OnShowSnackbar( + it.message ?: "댓글 작성에 실패했어요" + ) + ) + } + intent { copy(isLoading = false) } + } + } else { + viewModelScope.launch { + intent { copy(isLoading = true) } + newRecommentUseCase( + postId = postId, + content = currentState.commentText, + currentState.selectedCommentId!! + ).onSuccess { + postSideEffect(StudentAnnounceDetailEffect.OnShowSnackbar("댓글이 작성되었습니다")) + intent { copy(commentText = "", selectedCommentId = null)} + getAnnounceDetail() + }.onFailure { + postSideEffect( + StudentAnnounceDetailEffect.OnShowSnackbar( + it.message ?: "댓글 작성에 실패했어요" + ) + ) + } + intent { copy(isLoading = false) } + } + } + } + } +} \ No newline at end of file diff --git a/android/feature/student/home/src/main/java/com/sixkids/student/home/announce/announcelist/StudentAnnounceListContract.kt b/android/feature/student/home/src/main/java/com/sixkids/student/home/announce/announcelist/StudentAnnounceListContract.kt new file mode 100644 index 00000000..0dd87818 --- /dev/null +++ b/android/feature/student/home/src/main/java/com/sixkids/student/home/announce/announcelist/StudentAnnounceListContract.kt @@ -0,0 +1,16 @@ +package com.sixkids.student.home.announce.announcelist + +import com.sixkids.model.Post +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState + +sealed interface StudentAnnounceListEffect : SideEffect { + data object NavigateToAnnounceDetail: StudentAnnounceListEffect + data class OnShowSnackBar(val message : String) : StudentAnnounceListEffect +} + +data class StudentAnnounceListState( + val isLoding: Boolean = false, + val classString: String = "", + val postList: List = emptyList(), +): UiState \ No newline at end of file diff --git a/android/feature/student/home/src/main/java/com/sixkids/student/home/announce/announcelist/StudentAnnounceListScreen.kt b/android/feature/student/home/src/main/java/com/sixkids/student/home/announce/announcelist/StudentAnnounceListScreen.kt new file mode 100644 index 00000000..ac8dc2a5 --- /dev/null +++ b/android/feature/student/home/src/main/java/com/sixkids/student/home/announce/announcelist/StudentAnnounceListScreen.kt @@ -0,0 +1,140 @@ +package com.sixkids.student.home.announce.announcelist + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import com.sixkids.designsystem.component.appbar.UlbanDefaultAppBar +import com.sixkids.designsystem.component.appbar.UlbanDetailAppBar +import com.sixkids.designsystem.component.screen.LoadingScreen +import com.sixkids.designsystem.theme.Orange +import com.sixkids.designsystem.theme.OrangeDark +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.model.Post +import com.sixkids.student.home.R +import com.sixkids.student.home.announce.component.PostItem +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.util.formatToMonthDayTimeKorean +import com.sixkids.designsystem.R as UlbanRes + +@Composable +fun StudentAnnounceListRoute( + viewModel: StudentAnnounceListViewModel = hiltViewModel(), + navigateToStudentAnnounceDetail: (postId:Long) -> Unit, + onShowSnackBar: (SnackbarToken) -> Unit, + padding: PaddingValues +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.getAnnounceList() + } + + LaunchedEffect(key1 = viewModel.sideEffect) { + viewModel.sideEffect.collect { sideEffect -> + when (sideEffect) { + StudentAnnounceListEffect.NavigateToAnnounceDetail -> {} + is StudentAnnounceListEffect.OnShowSnackBar -> { + onShowSnackBar(SnackbarToken(message = sideEffect.message)) + } + } + } + } + + Box( + modifier = Modifier + .padding(padding) + .fillMaxSize() + ) { + StudentAnnounceListScreen( + studentAnnounceListState = uiState, + postItems = viewModel.postList?.collectAsLazyPagingItems(), + postItemOnclick = navigateToStudentAnnounceDetail, + ) + } +} + +@Composable +fun StudentAnnounceListScreen( + modifier: Modifier = Modifier, + studentAnnounceListState: StudentAnnounceListState = StudentAnnounceListState(), + postItems: LazyPagingItems? = null, + postItemOnclick: (postId: Long) -> Unit = {}, +) { + val listState = rememberLazyListState() + + Box( + modifier = modifier + .fillMaxSize() + ) { + Column { + UlbanDefaultAppBar( + leftIcon = UlbanRes.drawable.announce, + title = stringResource(id = R.string.student_home_main_announce), + content = stringResource(id = R.string.student_home_main_announce), + body = studentAnnounceListState.classString.replace("\n", " "), + color = Orange + ) + + if (postItems != null){ + if (postItems.itemCount == 0){ + Spacer(modifier = Modifier.weight(1f)) + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.student_announce_no_item), + textAlign = TextAlign.Center, + style = UlbanTypography.bodyLarge, + ) + Spacer(modifier = Modifier.weight(1f)) + } else { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + state = listState, + ) { + items(postItems.itemCount) { index -> + postItems[index]?.let { post -> + PostItem( + title = post.title, + writer = post.writer, + dateString = post.time.formatToMonthDayTimeKorean(), + commentCount = post.commentCount, + dividerColor = OrangeDark, + onClick = { postItemOnclick(post.id) } + ) + } + } + } + } + } + } + if (studentAnnounceListState.isLoding){ + LoadingScreen() + } + } +} + +@Preview(showBackground = true) +@Composable +fun StudentAnnounceListScreenPreview() { + StudentAnnounceListScreen() +} \ No newline at end of file diff --git a/android/feature/student/home/src/main/java/com/sixkids/student/home/announce/announcelist/StudentAnnounceListViewModel.kt b/android/feature/student/home/src/main/java/com/sixkids/student/home/announce/announcelist/StudentAnnounceListViewModel.kt new file mode 100644 index 00000000..d1cfe46a --- /dev/null +++ b/android/feature/student/home/src/main/java/com/sixkids/student/home/announce/announcelist/StudentAnnounceListViewModel.kt @@ -0,0 +1,53 @@ +package com.sixkids.student.home.announce.announcelist + +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn +import com.sixkids.domain.usecase.organization.GetSelectedOrganizationIdUseCase +import com.sixkids.domain.usecase.organization.LoadSelectedOrganizationNameUseCase +import com.sixkids.domain.usecase.post.GetPostListUseCase +import com.sixkids.model.Post +import com.sixkids.model.PostCategory +import com.sixkids.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class StudentAnnounceListViewModel @Inject constructor( + private val getPostListUseCase: GetPostListUseCase, + private val getSelectedOrganizationIdUseCase: GetSelectedOrganizationIdUseCase, + private val loadSelectedOrganizationNameUseCase: LoadSelectedOrganizationNameUseCase +): BaseViewModel(StudentAnnounceListState()){ + private var organizationId: Int? = null + + var postList: Flow>? = null + + fun getAnnounceList() { + viewModelScope.launch { + intent { copy(isLoding = true) } + + loadSelectedOrganizationNameUseCase().onSuccess { + intent { copy(classString = it) } + }.onFailure { + intent { copy(classString = "") } + } + + if (organizationId == null){ + organizationId = getSelectedOrganizationIdUseCase().getOrNull() + } + + if (organizationId != null){ + postList = getPostListUseCase( + organizationId = organizationId!!, + postCategory = PostCategory.NOTICE + ).cachedIn(viewModelScope) + } else { + postSideEffect(StudentAnnounceListEffect.OnShowSnackBar("학급 정보를 불러오지 못했어요 ;(")) + } + + intent { copy(isLoding = false) } + } + } +} \ No newline at end of file diff --git a/android/feature/student/home/src/main/java/com/sixkids/student/home/announce/component/CommentCount.kt b/android/feature/student/home/src/main/java/com/sixkids/student/home/announce/component/CommentCount.kt new file mode 100644 index 00000000..b9d2f0e8 --- /dev/null +++ b/android/feature/student/home/src/main/java/com/sixkids/student/home/announce/component/CommentCount.kt @@ -0,0 +1,37 @@ +package com.sixkids.student.home.announce.component + +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import com.sixkids.designsystem.theme.OrangeText +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.designsystem.R as UlbanRes + +@Composable +fun CommentCount( + count: Int +) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = ImageVector.vectorResource(id = UlbanRes.drawable.ic_chat_bubble), + contentDescription = null, + tint = OrangeText + ) + Text( + text = count.toString(), + style = UlbanTypography.bodyMedium + ) + } +} + + +@Preview(showBackground = true) +@Composable +fun CommentCountPreview() { + CommentCount(10) +} \ No newline at end of file diff --git a/android/feature/student/home/src/main/java/com/sixkids/student/home/announce/component/CommentItem.kt b/android/feature/student/home/src/main/java/com/sixkids/student/home/announce/component/CommentItem.kt new file mode 100644 index 00000000..72fd12dc --- /dev/null +++ b/android/feature/student/home/src/main/java/com/sixkids/student/home/announce/component/CommentItem.kt @@ -0,0 +1,133 @@ +package com.sixkids.student.home.announce.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Card +import androidx.compose.material3.CardColors +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.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.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.sixkids.designsystem.theme.Blue +import com.sixkids.designsystem.theme.Gray +import com.sixkids.designsystem.theme.GrayLight +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.designsystem.R as UlbanRes + +@Composable +fun CommentItem( + modifier: Modifier = Modifier, + selected: Boolean = false, + writer: String = "", + dateString: String = "00/00 00:00", + writerImageUrl: String = "", + commentString: String = "", + isRecomment: Boolean = false, + recommentOnclick: () -> Unit = {}, + deleteOnclick: (() -> Unit)? = null +){ + Card( + colors = CardDefaults.cardColors( + containerColor = + if (selected) { Blue} + else if (isRecomment) {GrayLight} + else {Color.Transparent} + ), + ) { + Column( + modifier = modifier + .padding(start = 10.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + AsyncImage( + modifier = Modifier + .height(36.dp) + .aspectRatio(1f), + model = writerImageUrl, + contentScale = ContentScale.Crop, + contentDescription = "작성자 프로필 사진" + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = writer, + style = UlbanTypography.bodyMedium + ) + Spacer(modifier = Modifier.weight(1f)) + if (!isRecomment) { + Icon( + modifier = Modifier.clickable{recommentOnclick()}, + imageVector = ImageVector.vectorResource(id = UlbanRes.drawable.ic_chat_bubble_outline), + contentDescription = null + ) + } + if (deleteOnclick != null) { + Icon( + modifier = Modifier.clickable{deleteOnclick()}, + imageVector = ImageVector.vectorResource(id = UlbanRes.drawable.ic_delete), + contentDescription = null + ) + } + } + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = commentString, + style = UlbanTypography.bodyMedium + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = dateString, + style = UlbanTypography.bodySmall.copy( + color = Gray + ) + ) + Spacer(modifier = Modifier.height(4.dp)) + } + } +} + +@Preview(showBackground = true) +@Composable +fun CommentItemPreview() { + Column { + CommentItem( + writer = "오하빈", + dateString = "09/01 12:00", + writerImageUrl = "https://www.google.com/images/branding/googlelogo/2x/googlelogo_light_color_272x92dp.png", + commentString = "댓글 내용", + deleteOnclick = {}, + selected = true + ) + CommentItem( + writer = "오하빈", + dateString = "09/01 12:00", + commentString = "댓글 내용", + writerImageUrl = "https://www.google.com/images/branding/googlelogo/2x/googlelogo_light_color_272x92dp.png", + ) + CommentItem( + writer = "오하빈", + dateString = "09/01 12:00", + commentString = "댓글 내용", + writerImageUrl = "https://www.google.com/images/branding/googlelogo/2x/googlelogo_light_color_272x92dp.png", + isRecomment = true, + deleteOnclick = {} + ) + } + +} \ No newline at end of file diff --git a/android/feature/student/home/src/main/java/com/sixkids/student/home/announce/component/CommentTextField.kt b/android/feature/student/home/src/main/java/com/sixkids/student/home/announce/component/CommentTextField.kt new file mode 100644 index 00000000..dbdb94b8 --- /dev/null +++ b/android/feature/student/home/src/main/java/com/sixkids/student/home/announce/component/CommentTextField.kt @@ -0,0 +1,77 @@ +package com.sixkids.student.home.announce.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.Send +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.sixkids.designsystem.component.textfield.UlbanBasicTextField +import com.sixkids.designsystem.theme.GrayLight +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.student.home.R + + +@Composable +fun CommentTextField( + msg: String = "", + onTextIuputChange: (String) -> Unit = {}, + onSendClick: (String) -> Unit = {}, +) { + Card( + modifier = Modifier + .padding(6.dp), + colors = CardDefaults.cardColors( + containerColor = GrayLight + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + UlbanBasicTextField( + text = msg, + onTextChange = onTextIuputChange, + modifier = Modifier + .padding(10.dp, 0.dp) + .weight(1f) + .wrapContentHeight(), + maxLines = 3, + textStyle = UlbanTypography.bodyMedium.copy(fontWeight = FontWeight.Normal), + hint = stringResource(id = R.string.student_announce_detail_comment_hint) + ) + + Icon( + Icons.AutoMirrored.Outlined.Send, + contentDescription = "", + modifier = Modifier + .size(30.dp) + .padding(end = 4.dp) + .clickable { + onSendClick(msg) + } + ) + + } + } + +} + +@Preview(showBackground = true) +@Composable +fun CommentTextFieldPreview() { + CommentTextField() +} \ No newline at end of file diff --git a/android/feature/student/home/src/main/java/com/sixkids/student/home/announce/component/PageTitle.kt b/android/feature/student/home/src/main/java/com/sixkids/student/home/announce/component/PageTitle.kt new file mode 100644 index 00000000..7e8ca2fb --- /dev/null +++ b/android/feature/student/home/src/main/java/com/sixkids/student/home/announce/component/PageTitle.kt @@ -0,0 +1,55 @@ +package com.sixkids.student.home.announce.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.designsystem.R as UlbanRes + +@Composable +fun PageTitle( + modifier: Modifier = Modifier, + title: String, + cancelOnclick: () -> Unit = {}, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + modifier = Modifier + .clickable { cancelOnclick() }, + imageVector = ImageVector.vectorResource(id = UlbanRes.drawable.ic_cancel_post), + contentDescription = null + ) + Spacer(modifier = Modifier.width(14.dp)) + Text( + text = title, + style = UlbanTypography.titleLarge.copy( + fontWeight = FontWeight.Medium, + ), + ) + } +} + + +@Preview(showBackground = true) +@Composable +fun PageTitlePreview() { + PageTitle( + title = "글 쓰기", + cancelOnclick = {} + ) +} \ No newline at end of file diff --git a/android/feature/student/home/src/main/java/com/sixkids/student/home/announce/component/PostItem.kt b/android/feature/student/home/src/main/java/com/sixkids/student/home/announce/component/PostItem.kt new file mode 100644 index 00000000..8639fde6 --- /dev/null +++ b/android/feature/student/home/src/main/java/com/sixkids/student/home/announce/component/PostItem.kt @@ -0,0 +1,80 @@ +package com.sixkids.student.home.announce.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.layout.width +import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.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 com.sixkids.designsystem.theme.UlbanTypography + +@Composable +fun PostItem( + modifier: Modifier = Modifier , + title: String, + writer: String, + commentCount: Int, + dateString: String, + dividerColor: Color = Color.Black, + onClick: () -> Unit = {} +) { + Column( + modifier = modifier.padding(bottom = 8.dp).clickable { onClick() } + ) { + Text( + text = title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = UlbanTypography.titleMedium + ) + Spacer(modifier = Modifier.height(10.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + if (commentCount > 0){ + CommentCount(count = commentCount) + } + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = writer, + style = UlbanTypography.bodyMedium + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + text = dateString, + style = UlbanTypography.bodyMedium + ) + } + HorizontalDivider( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + thickness = 2.dp, + color = dividerColor + ) + } +} + +@Preview(showBackground = true) +@Composable +fun PostItemPreview() { + PostItem( + title = "이따 마크 할 사람~~!", + writer = "오하빈", + commentCount = 3, + dateString = "2024.04.16 14:30" + ) +} \ No newline at end of file diff --git a/android/feature/student/home/src/main/java/com/sixkids/student/home/announce/component/PostWriterInfo.kt b/android/feature/student/home/src/main/java/com/sixkids/student/home/announce/component/PostWriterInfo.kt new file mode 100644 index 00000000..29ac07db --- /dev/null +++ b/android/feature/student/home/src/main/java/com/sixkids/student/home/announce/component/PostWriterInfo.kt @@ -0,0 +1,64 @@ +package com.sixkids.student.home.announce.component + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.sixkids.designsystem.theme.UlbanTypography + +@Composable +fun PostWriterInfo( + height: Dp = 60.dp, + writer: String = "", + dateString: String = "00/00 00:00", + writerImageUrl: String = "" +) { + Row( + modifier = Modifier.height(height), + verticalAlignment = Alignment.CenterVertically + ) { + AsyncImage( + modifier = Modifier + .fillMaxHeight() + .aspectRatio(1f), + model = writerImageUrl, + contentScale = ContentScale.Crop, + contentDescription = "프로필 사진" + ) + Spacer(modifier = Modifier.width(8.dp)) + Column { + Text( + text = writer, + style = UlbanTypography.bodyLarge + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = dateString, + style = UlbanTypography.bodyMedium + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun PostWriterInfoPreview() { + PostWriterInfo( + height = 60.dp, + writer = "홍유준 선생님", + dateString = "10/10 10:10", + writerImageUrl = "" + ) +} \ No newline at end of file diff --git a/android/feature/student/home/src/main/java/com/sixkids/student/home/chatting/StudentChattingContract.kt b/android/feature/student/home/src/main/java/com/sixkids/student/home/chatting/StudentChattingContract.kt new file mode 100644 index 00000000..a25a2b24 --- /dev/null +++ b/android/feature/student/home/src/main/java/com/sixkids/student/home/chatting/StudentChattingContract.kt @@ -0,0 +1,18 @@ +package com.sixkids.student.home.chatting + +import com.sixkids.model.Chat +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState + +sealed interface StudentChattingSideEffect : SideEffect{ + +} + +data class StudentChattingState( + val isLoading : Boolean = false, + val organizationName: String = "", + val memberCount : Int = 0, + val memberId: Int = 0, + val message: String = "", + val chatList: List = emptyList() +) : UiState \ No newline at end of file diff --git a/android/feature/student/home/src/main/java/com/sixkids/student/home/chatting/StudentChattingScreen.kt b/android/feature/student/home/src/main/java/com/sixkids/student/home/chatting/StudentChattingScreen.kt new file mode 100644 index 00000000..e5fab5ef --- /dev/null +++ b/android/feature/student/home/src/main/java/com/sixkids/student/home/chatting/StudentChattingScreen.kt @@ -0,0 +1,354 @@ +package com.sixkids.student.home.chatting + +import android.annotation.SuppressLint +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.Send +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +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 androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import coil.compose.AsyncImage +import com.sixkids.designsystem.component.textfield.UlbanBasicTextField +import com.sixkids.designsystem.theme.Cream +import com.sixkids.designsystem.theme.Gray +import com.sixkids.designsystem.theme.GrayLight +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.designsystem.theme.Yellow +import com.sixkids.model.Chat +import com.sixkids.student.home.R +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.extension.collectWithLifecycle +import java.text.SimpleDateFormat +import java.util.TimeZone +import com.sixkids.designsystem.R as UlbanRes + +@Composable +fun StudentChattingRoute( + viewModel: StudentChattingViewModel = hiltViewModel(), + onBackClick: () -> Unit, + onShowSnackBar: (SnackbarToken) -> Unit +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + viewModel.sideEffect.collectWithLifecycle { sideEffect -> + when (sideEffect) { + else -> {} + } + } + + DisposableEffect(key1 = Unit) { + viewModel.initStomp() + + onDispose { + viewModel.cancelStomp() + } + } + + StudentChattingScreen( + uiState = uiState, + onUpdateMessage = viewModel::updateMessage, + onBackClick = onBackClick, + onSendClick = viewModel::sendMessage, + onPhotoClick = { + //사진 + }, + chatItems = viewModel.originalChatList?.collectAsLazyPagingItems() + ) +} + +@Composable +fun StudentChattingScreen( + uiState: StudentChattingState = StudentChattingState(), + onUpdateMessage: (String) -> Unit = {}, + onBackClick: () -> Unit = {}, + onSendClick: (String) -> Unit = {}, + onPhotoClick: () -> Unit = {}, + chatItems: LazyPagingItems? = null +) { + Box( + modifier = Modifier + .fillMaxSize() + ) { + Column { + TopSection( + uiState.organizationName, + onBackClick + ) + + ChatSection( + uiState.memberId, + chatItems = chatItems, + uiState.chatList, + modifier = Modifier + .weight(1f) + .fillMaxSize() + ) + + InputSection( + msg = uiState.message, + onUpdateMessage = onUpdateMessage, + onSendClick = onSendClick, + onPhotoClick = onPhotoClick + ) + } + } +} + + +@Composable +fun TopSection( + organizationName: String = "", + onBackClick: () -> Unit = {} +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(id = UlbanRes.drawable.ic_arrow_back), + contentDescription = "back button", + modifier = Modifier.clickable { onBackClick() } + ) + + Text( + text = organizationName.replace("\n", " "), + style = UlbanTypography.titleSmall, + modifier = Modifier.padding(10.dp, 0.dp) + ) + + } +} + +@Composable +fun ChatSection( + memberId: Int, + chatItems: LazyPagingItems? = null, + chatList: List, + modifier: Modifier = Modifier +) { +// Log.d(TAG, "ChatSection: ") + val scrollState = rememberLazyListState() + + if (chatItems == null) { + Text(text = "데이터 없음") + } else { + Column(modifier = modifier) { + LazyColumn( + state = scrollState, modifier = Modifier.weight(1f), + + ) { + items(chatItems.itemCount) { idx -> + if (chatItems[idx]?.memberId == memberId.toLong()) { + MyChat(chatItems[idx]!!) + } else { + OtherChat(chatItems[idx]!!) + } + } + + items(chatList) { chat -> + if (chat.memberId == memberId.toLong()) { + MyChat(chat) + } else { + OtherChat(chat) + } + } + + + } + } + } + val serverChatSize = chatItems?.itemCount ?: 0 + val socketChatSize = chatList.size + val totalChatSize = serverChatSize + socketChatSize + + if (totalChatSize > 0) { + LaunchedEffect(totalChatSize) { + scrollState.scrollToItem(totalChatSize - 1) + } + } +} + +@Composable +fun OtherChat(chat: Chat) { + val screenWidthDp = with(LocalDensity.current) { + LocalContext.current.resources.displayMetrics.widthPixels.toDp() + } + val maxWidthDp = screenWidthDp * 0.6f + Column( + modifier = Modifier + .fillMaxWidth() + .padding(6.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + AsyncImage( + model = chat.memberImageUrl, + contentDescription = "profile photo", + modifier = Modifier + .size(32.dp) + .clip(RoundedCornerShape(4.dp)), + contentScale = ContentScale.Crop + ) + Text( + text = chat.memberName, + style = UlbanTypography.bodySmall, + modifier = Modifier.padding(10.dp) + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp, 0.dp, 0.dp, 10.dp), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.Bottom + ) { + + Text( + text = chat.content, + style = UlbanTypography.bodyMedium.copy(fontWeight = FontWeight.Normal), + modifier = Modifier + .background(GrayLight, shape = RoundedCornerShape(10.dp)) + .padding(12.dp) + .widthIn(max = maxWidthDp) + .wrapContentWidth() + ) + Text( + text = chatTimeFormat(chat.sendDateTime), + style = UlbanTypography.bodySmall.copy( + fontSize = 8.sp, + lineHeight = 10.sp, + fontWeight = FontWeight.Normal + ), + modifier = Modifier.padding(4.dp, 0.dp, 0.dp, 6.dp) + ) + } + } +} + +@Composable +fun MyChat(chat: Chat) { + val screenWidthDp = with(LocalDensity.current) { + LocalContext.current.resources.displayMetrics.widthPixels.toDp() + } + val maxWidthDp = screenWidthDp * 0.6f + Row( + modifier = Modifier + .fillMaxWidth() + .padding(6.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.Bottom + ) { + Text( + text = chatTimeFormat(chat.sendDateTime), + style = UlbanTypography.bodySmall.copy( + fontSize = 8.sp, + lineHeight = 10.sp, + fontWeight = FontWeight.Normal + ), + modifier = Modifier.padding(0.dp, 0.dp, 4.dp, 6.dp) + ) + Text( + text = chat.content, + style = UlbanTypography.bodyMedium.copy(fontWeight = FontWeight.Normal), + modifier = Modifier + .background(Yellow, shape = RoundedCornerShape(10.dp)) + .padding(12.dp) + .widthIn(max = maxWidthDp) + ) + } +} + +@SuppressLint("SimpleDateFormat") +fun chatTimeFormat(time: Long): String { + val formatter = SimpleDateFormat("MM/dd\na h:mm").apply { + timeZone = TimeZone.getTimeZone("Asia/Seoul") + } + + return formatter.format(time) +} + +@Composable +fun InputSection( + msg: String = "", + onUpdateMessage: (String) -> Unit = {}, + onSendClick: (String) -> Unit = {}, + onPhotoClick: () -> Unit = {} +) { + + Row( + modifier = Modifier + .fillMaxWidth() + .background(Cream) + .padding(6.dp), + verticalAlignment = Alignment.Bottom + ) { + + UlbanBasicTextField( + text = msg, + onTextChange = onUpdateMessage, + modifier = Modifier + .padding(10.dp, 0.dp) + .weight(1f) + .wrapContentHeight(), + maxLines = 3, + textStyle = UlbanTypography.bodyMedium.copy(fontWeight = FontWeight.Normal) + + ) + + Icon( + Icons.AutoMirrored.Outlined.Send, + contentDescription = "", + modifier = Modifier + .size(30.dp) + .clickable { + onSendClick(msg) + } + ) + + } + + +} + +@Preview(showBackground = true) +@Composable +fun StudentChattingScreenPreview() { + StudentChattingScreen() +} \ No newline at end of file diff --git a/android/feature/student/home/src/main/java/com/sixkids/student/home/chatting/StudentChattingViewModel.kt b/android/feature/student/home/src/main/java/com/sixkids/student/home/chatting/StudentChattingViewModel.kt new file mode 100644 index 00000000..9af10086 --- /dev/null +++ b/android/feature/student/home/src/main/java/com/sixkids/student/home/chatting/StudentChattingViewModel.kt @@ -0,0 +1,183 @@ +package com.sixkids.student.home.chatting + +import android.annotation.SuppressLint +import android.util.Log +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn +import com.sixkids.domain.usecase.chatting.GetChattingHistoryUseCase +import com.sixkids.domain.usecase.organization.GetSelectedOrganizationIdUseCase +import com.sixkids.domain.usecase.organization.LoadSelectedOrganizationNameUseCase +import com.sixkids.domain.usecase.user.GetATKUseCase +import com.sixkids.domain.usecase.user.LoadUserInfoUseCase +import com.sixkids.model.Chat +import com.sixkids.model.ChatMessage +import com.sixkids.model.UserInfo +import com.sixkids.student.home.BuildConfig +import com.sixkids.ui.base.BaseViewModel +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import org.hildan.krossbow.stomp.StompClient +import org.hildan.krossbow.stomp.StompSession +import org.hildan.krossbow.stomp.conversions.convertAndSend +import org.hildan.krossbow.stomp.conversions.moshi.withMoshi +import org.hildan.krossbow.stomp.frame.StompFrame +import org.hildan.krossbow.stomp.headers.StompSendHeaders +import org.hildan.krossbow.stomp.headers.StompSubscribeHeaders +import org.hildan.krossbow.websocket.okhttp.OkHttpWebSocketClient +import javax.inject.Inject + +private const val TAG = "D107" + +@HiltViewModel +class StudentChattingViewModel @Inject constructor( + private val getATKUseCase: GetATKUseCase, + private val getSelectedOrganizationIdUseCase: GetSelectedOrganizationIdUseCase, + private val loadSelectedOrganizationNameUseCase: LoadSelectedOrganizationNameUseCase, + private val loadUserInfoUseCase: LoadUserInfoUseCase, + private val getChattingHistoryUseCase: GetChattingHistoryUseCase +) : BaseViewModel( + StudentChattingState() +) { + + private var roomId = 1L + private lateinit var tkn: String + private lateinit var userInfo: UserInfo + + private lateinit var stompSession: StompSession + private val moshi: Moshi = Moshi.Builder() + .addLast(KotlinJsonAdapterFactory()) + .build() + private lateinit var newChatMessage: Flow + + private lateinit var chattingList: List + + var originalChatList: Flow>? = null + + init { + viewModelScope.launch { + loadSelectedOrganizationNameUseCase().onSuccess { + Log.d(TAG, "initStomp1: $it") + intent { copy(organizationName = it) } + }.onFailure { + Log.d(TAG, "initStomp1: $it") + } + } + } + @SuppressLint("CheckResult") + fun initStomp() { + viewModelScope.launch { + try { + loadLocalData() + + originalChatList = + getChattingHistoryUseCase(roomId).cachedIn(viewModelScope) + + } catch (e: Exception) { + Log.d(TAG, "initStomp: ${e.message}") + } + } + } + + private fun loadLocalData() { + viewModelScope.launch { + val tknJob = async { getATKUseCase().getOrThrow() } + val roomIdJob = async { getSelectedOrganizationIdUseCase().getOrThrow() } + val userInfoJob = async { loadUserInfoUseCase().getOrThrow() } + + tkn = tknJob.await() + roomId = roomIdJob.await().toLong() + userInfo = userInfoJob.await() + + connectStomp() + + intent { copy(memberId = userInfo.id) } + } + } + + suspend fun connectStomp(){ + viewModelScope.launch { + val okHttpClient = OkHttpClient.Builder() + .addInterceptor( + HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY + } + ) + .build() + + val client = StompClient( + OkHttpWebSocketClient(okHttpClient) + ) + + stompSession = client.connect( + BuildConfig.STOMP_ENDPOINT, + customStompConnectHeaders = mapOf( + HEADER_AUTHORIZATION to tkn, + HEADER_ROOM_ID to roomId.toString() + ) + ).withMoshi(moshi) + + newChatMessage = stompSession.subscribe( + StompSubscribeHeaders( + destination = "$SUBSCRIBE_URL$roomId", + customHeaders = mapOf( + HEADER_AUTHORIZATION to tkn + ) + ) + ) + + + newChatMessage.collect { + val chatMessage = moshi.adapter(Chat::class.java).fromJson(it.bodyAsText) + intent { copy(chatList = chatList + chatMessage!!) } + } + + + } + } + + fun updateMessage(message: String) { + intent { copy(message = message) } + } + + fun sendMessage(message: String) { + viewModelScope.launch { + Log.d(TAG, "sendMessage: ${userInfo.photo}") + stompSession.withMoshi(moshi).convertAndSend( + StompSendHeaders( + destination = SEND_URL, + customHeaders = mapOf( + HEADER_AUTHORIZATION to tkn + ) + ), + ChatMessage(roomId, userInfo.photo, message) + ) + } + intent { copy(message = "") } + } + + fun cancelStomp() { + try { + viewModelScope.launch { + stompSession.disconnect() + } + } catch (e: Exception) { + Log.d(TAG, "cancelStomp: ${e.message}") + } + } + + companion object { + const val HEADER_AUTHORIZATION = "Authorization" + const val HEADER_ROOM_ID = "roomId" + + const val SEND_URL = "/publish/chat/message" + const val SUBSCRIBE_URL = "/subscribe/public/" + } + +} \ No newline at end of file diff --git a/android/feature/student/home/src/main/java/com/sixkids/student/home/greeting/receiver/GreetingReceiverContract.kt b/android/feature/student/home/src/main/java/com/sixkids/student/home/greeting/receiver/GreetingReceiverContract.kt new file mode 100644 index 00000000..ab8f68d8 --- /dev/null +++ b/android/feature/student/home/src/main/java/com/sixkids/student/home/greeting/receiver/GreetingReceiverContract.kt @@ -0,0 +1,17 @@ +package com.sixkids.student.home.greeting.receiver + +import com.sixkids.model.GreetingNFC +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState + +data class GreetingReceiverState( + val organizationId: Int = -1, + val greetingNfc: GreetingNFC = GreetingNFC() +): UiState + +sealed interface GreetingReceiverEffect: SideEffect{ + data class OnShowSnackBar(val tkn : SnackbarToken) : GreetingReceiverEffect + + data object NavigateToHome : GreetingReceiverEffect +} \ No newline at end of file diff --git a/android/feature/student/home/src/main/java/com/sixkids/student/home/greeting/receiver/GreetingReceiverScreen.kt b/android/feature/student/home/src/main/java/com/sixkids/student/home/greeting/receiver/GreetingReceiverScreen.kt new file mode 100644 index 00000000..3fb69e19 --- /dev/null +++ b/android/feature/student/home/src/main/java/com/sixkids/student/home/greeting/receiver/GreetingReceiverScreen.kt @@ -0,0 +1,86 @@ +package com.sixkids.student.home.greeting.receiver + +import android.app.Activity +import android.nfc.NfcAdapter +import android.nfc.Tag +import android.nfc.tech.IsoDep +import android.util.Log +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.sixkids.core.nfc.HCEService +import com.sixkids.designsystem.component.screen.GreetingScreen +import com.sixkids.model.GreetingNFC +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.extension.collectWithLifecycle +import kotlinx.serialization.json.Json + +private const val TAG = "D107" +@Composable +fun GreetingReceiverRoute( + viewModel: GreetingReceiverViewModel = hiltViewModel(), + onBackClick : () -> Unit = {}, + onShowSnackBar : (SnackbarToken) -> Unit = {} +){ + val context = LocalContext.current + val activity = context as Activity + + val uiState = viewModel.uiState.collectAsStateWithLifecycle().value + val nfcAdapter: NfcAdapter = NfcAdapter.getDefaultAdapter(context) + + DisposableEffect(key1 = Unit) { + viewModel.initData() + + onDispose { + nfcAdapter.disableReaderMode(activity) + } + } + + viewModel.sideEffect.collectWithLifecycle { + when(it){ + is GreetingReceiverEffect.OnShowSnackBar -> onShowSnackBar(it.tkn) + is GreetingReceiverEffect.NavigateToHome -> onBackClick() + } + } + + if (uiState.organizationId != -1 && nfcAdapter.isEnabled) { + Log.d(TAG, "RelayTaggingReceiverRoute: Ready!") + nfcAdapter.enableReaderMode(activity, { tag : Tag? -> + tag?.let { + val isoDep = IsoDep.get(it) + isoDep.use { iso -> + iso.connect() + val response = isoDep.transceive(HCEService.SELECT_APDU) + val message = String(response.copyOfRange(0, response.size - 2)) + Log.d(TAG, "GreetingReceiverRoute: $message") + val greetingNfc = Json.decodeFromString(message) + viewModel.onNfcReceived(greetingNfc) + } + } + }, NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK, null) + + } + + GreetingReceiverScreen() +} + +@Composable +fun GreetingReceiverScreen(){ + Box(modifier = Modifier.fillMaxSize()){ + GreetingScreen( + isSender = false + ) + } +} + +@Composable +@Preview(showBackground = true) +fun GreetingReceiverScreenPreview(){ + GreetingReceiverScreen() +} \ No newline at end of file diff --git a/android/feature/student/home/src/main/java/com/sixkids/student/home/greeting/receiver/GreetingReceiverViewModel.kt b/android/feature/student/home/src/main/java/com/sixkids/student/home/greeting/receiver/GreetingReceiverViewModel.kt new file mode 100644 index 00000000..46123b84 --- /dev/null +++ b/android/feature/student/home/src/main/java/com/sixkids/student/home/greeting/receiver/GreetingReceiverViewModel.kt @@ -0,0 +1,55 @@ +package com.sixkids.student.home.greeting.receiver + +import androidx.lifecycle.viewModelScope +import com.sixkids.domain.usecase.organization.GetSelectedOrganizationIdUseCase +import com.sixkids.domain.usecase.organization.GreetingUseCase +import com.sixkids.model.GreetingNFC +import com.sixkids.model.NotFoundException +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class GreetingReceiverViewModel @Inject constructor( + private val getSelectedOrganizationIdUseCase: GetSelectedOrganizationIdUseCase, + private val greetingUseCase: GreetingUseCase +): BaseViewModel(GreetingReceiverState()){ + + fun initData(){ + viewModelScope.launch { + getSelectedOrganizationIdUseCase() + .onSuccess { + intent { copy(organizationId = it) } + } + .onFailure { + intent { copy(organizationId = -1) } + } + } + } + + fun onNfcReceived(greetingNfc: GreetingNFC){ + if (greetingNfc.organizationId == uiState.value.organizationId){ + viewModelScope.launch { + greetingUseCase(uiState.value.organizationId.toLong(), greetingNfc.senderId.toLong()) + .onSuccess { + if (it > 0){ + postSideEffect(GreetingReceiverEffect.OnShowSnackBar(SnackbarToken("친구와 인사를 주고 받았어요!"))) + postSideEffect(GreetingReceiverEffect.NavigateToHome) + }else{ + postSideEffect(GreetingReceiverEffect.OnShowSnackBar(SnackbarToken("인사에 실패했어요"))) + } + } + .onFailure { + when(it){ + is NotFoundException -> postSideEffect(GreetingReceiverEffect.OnShowSnackBar(SnackbarToken("같은 반 친구가 아닙니다!"))) + else -> postSideEffect(GreetingReceiverEffect.OnShowSnackBar(SnackbarToken("인사에 실패했어요"))) + } + } + } + }else{ + postSideEffect(GreetingReceiverEffect.OnShowSnackBar(SnackbarToken("같은 반 친구가 아닙니다!"))) + } + } +} \ No newline at end of file diff --git a/android/feature/student/home/src/main/java/com/sixkids/student/home/greeting/sender/GreetingSenderContract.kt b/android/feature/student/home/src/main/java/com/sixkids/student/home/greeting/sender/GreetingSenderContract.kt new file mode 100644 index 00000000..3514fa8e --- /dev/null +++ b/android/feature/student/home/src/main/java/com/sixkids/student/home/greeting/sender/GreetingSenderContract.kt @@ -0,0 +1,13 @@ +package com.sixkids.student.home.greeting.sender + +import com.sixkids.model.GreetingNFC +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState + +data class GreetingSenderState( + val greetingNfc: GreetingNFC = GreetingNFC() +): UiState + +sealed interface GreetingSenderEffect: SideEffect { + +} \ No newline at end of file diff --git a/android/feature/student/home/src/main/java/com/sixkids/student/home/greeting/sender/GreetingSenderScreen.kt b/android/feature/student/home/src/main/java/com/sixkids/student/home/greeting/sender/GreetingSenderScreen.kt new file mode 100644 index 00000000..4886d63c --- /dev/null +++ b/android/feature/student/home/src/main/java/com/sixkids/student/home/greeting/sender/GreetingSenderScreen.kt @@ -0,0 +1,61 @@ +package com.sixkids.student.home.greeting.sender + +import android.util.Log +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.sixkids.core.nfc.HCEService +import com.sixkids.designsystem.component.screen.GreetingScreen +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +private const val TAG = "D107" +@Composable +fun GreetingSenderRoute( + viewModel: GreetingSenderViewModel = hiltViewModel(), + onBackClick: () -> Unit, +){ + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(key1 = Unit) { + viewModel.initData() + } + + GreetingSenderScreen( + uiState = uiState, + onBackClick = onBackClick + ) + +} + +@Composable +fun GreetingSenderScreen( + uiState: GreetingSenderState = GreetingSenderState(), + onBackClick: () -> Unit = {}, +){ + if (uiState.greetingNfc.organizationId != -1) { + val serializedGreetingNfc = Json.encodeToString(uiState.greetingNfc) + Log.d(TAG, "RelayTaggingSenderScreen: $serializedGreetingNfc") + HCEService.setData(serializedGreetingNfc) + } + Box(modifier = Modifier.fillMaxSize()) { + GreetingScreen( + isSender = true, + onClick = onBackClick + ) + } + + +} + +@Composable +@Preview(showBackground = true) +fun GreetingSenderScreenPreview(){ + GreetingSenderScreen() +} \ No newline at end of file diff --git a/android/feature/student/home/src/main/java/com/sixkids/student/home/greeting/sender/GreetingSenderViewModel.kt b/android/feature/student/home/src/main/java/com/sixkids/student/home/greeting/sender/GreetingSenderViewModel.kt new file mode 100644 index 00000000..15f595de --- /dev/null +++ b/android/feature/student/home/src/main/java/com/sixkids/student/home/greeting/sender/GreetingSenderViewModel.kt @@ -0,0 +1,39 @@ +package com.sixkids.student.home.greeting.sender + +import android.util.Log +import androidx.lifecycle.viewModelScope +import com.sixkids.domain.usecase.organization.GetSelectedOrganizationIdUseCase +import com.sixkids.domain.usecase.user.LoadUserInfoUseCase +import com.sixkids.model.GreetingNFC +import com.sixkids.model.UserInfo +import com.sixkids.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +private const val TAG = "D107" +@HiltViewModel +class GreetingSenderViewModel @Inject constructor( + private val loadUserInfoUseCase: LoadUserInfoUseCase, + private val getSelectedOrganizationIdUseCase: GetSelectedOrganizationIdUseCase +): BaseViewModel(GreetingSenderState()){ + private var organizationId: Int = -1 + private lateinit var userInfo: UserInfo + + fun initData(){ + viewModelScope.launch { + loadUserInfoUseCase().onSuccess {user -> + userInfo = user + + getSelectedOrganizationIdUseCase() + .onSuccess { + organizationId = it + intent { copy(greetingNfc = GreetingNFC(user.id, it)) } + } + }.onFailure { + Log.d(TAG, "initData: $it") + } + } + } + +} \ No newline at end of file diff --git a/android/feature/student/home/src/main/java/com/sixkids/student/home/main/StudentHomeMainContract.kt b/android/feature/student/home/src/main/java/com/sixkids/student/home/main/StudentHomeMainContract.kt new file mode 100644 index 00000000..cb2e1380 --- /dev/null +++ b/android/feature/student/home/src/main/java/com/sixkids/student/home/main/StudentHomeMainContract.kt @@ -0,0 +1,23 @@ +package com.sixkids.student.home.main + +import com.sixkids.model.MemberSimpleWithScore +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState + +sealed interface StudentHomeMainEffect: SideEffect { + data object navigateToAnnounce: StudentHomeMainEffect + data object navigateToTagHello : StudentHomeMainEffect + data object navigateToRank : StudentHomeMainEffect + data object navigateToChatting : StudentHomeMainEffect + data class onShowSnackBar(val message: String) : StudentHomeMainEffect +} + +data class StudentHomeMainState( + val isLoading: Boolean = false, + val studentName: String = "", + val studentImageUrl: String = "", + val studentClass: String = "", + val studentExp: Int = 0, + val bestFriendList: List = emptyList(), + val isShowGreetingDialog: Boolean = false +): UiState \ No newline at end of file diff --git a/android/feature/student/home/src/main/java/com/sixkids/student/home/main/StudentHomeMainScreen.kt b/android/feature/student/home/src/main/java/com/sixkids/student/home/main/StudentHomeMainScreen.kt new file mode 100644 index 00000000..96258628 --- /dev/null +++ b/android/feature/student/home/src/main/java/com/sixkids/student/home/main/StudentHomeMainScreen.kt @@ -0,0 +1,241 @@ +package com.sixkids.student.home.main + +import android.util.Log +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +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.wrapContentHeight +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.sixkids.designsystem.component.item.StudentSimpleCardItem +import com.sixkids.designsystem.theme.Blue +import com.sixkids.designsystem.theme.BlueText +import com.sixkids.designsystem.theme.Orange +import com.sixkids.designsystem.theme.OrangeText +import com.sixkids.designsystem.theme.Purple +import com.sixkids.designsystem.theme.PurpleText +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.designsystem.theme.Yellow +import com.sixkids.designsystem.theme.YellowText +import com.sixkids.designsystem.theme.component.card.ContentVerticalCard +import com.sixkids.model.MemberSimple +import com.sixkids.model.MemberSimpleWithScore +import com.sixkids.student.home.R +import com.sixkids.student.home.main.component.GreetingDialog +import com.sixkids.student.home.main.component.StudentMainInfo +import com.sixkids.ui.SnackbarToken +import com.sixkids.designsystem.R as UlbanRes + +private const val TAG = "D107" +@Composable +fun StudentHomeMainRoute( + viewModel: StudentHomeMainViewModel = hiltViewModel(), + padding: PaddingValues, + navigateToAnnounce: () -> Unit, + navigateToTagHello: () -> Unit, + navigateToRank: () -> Unit, + navigateToChatting: () -> Unit, + navigateToGreetingSender: () -> Unit, + navigateToGreetingReceiver: () -> Unit, + onShowSnackBar: (SnackbarToken) -> Unit +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.getStudentHomeInfo() + } + + LaunchedEffect(viewModel.sideEffect) { + viewModel.sideEffect.collect { sideEffect -> + when (sideEffect) { + StudentHomeMainEffect.navigateToAnnounce -> navigateToAnnounce() + StudentHomeMainEffect.navigateToChatting -> navigateToChatting() + StudentHomeMainEffect.navigateToRank -> navigateToRank() + StudentHomeMainEffect.navigateToTagHello -> navigateToTagHello() + is StudentHomeMainEffect.onShowSnackBar -> onShowSnackBar(SnackbarToken(message = sideEffect.message)) + } + } + } + + Box( + modifier = Modifier + .padding(padding) + .fillMaxSize() + ) { + StudentHomeMainScreen( + studentHomeMainState = uiState, + announceCardOnClick = navigateToAnnounce, + chattingCardOnClick = navigateToChatting, + rankCardOnClick = navigateToRank, + showGreetingDialog = viewModel::showGreetingDialog, + ) + if (uiState.isShowGreetingDialog) { + BackHandler(enabled = true) { + Log.d(TAG, "BackHandler triggered") + viewModel.offDialog() + } + + GreetingDialog( + senderClick = navigateToGreetingSender, + receiverClick = navigateToGreetingReceiver, + cancelClick = viewModel::offDialog + ) + } + } +} + +@Composable +fun StudentHomeMainScreen( + modifier: Modifier = Modifier, + studentHomeMainState: StudentHomeMainState = StudentHomeMainState(), + announceCardOnClick: () -> Unit = {}, + chattingCardOnClick: () -> Unit = {}, + rankCardOnClick: () -> Unit = {}, + showGreetingDialog: () -> Unit = {}, +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(20.dp) + ) { + Spacer(modifier = Modifier.height(10.dp)) + + StudentMainInfo( + name = studentHomeMainState.studentName, + imageUrlString = studentHomeMainState.studentImageUrl, + classString = studentHomeMainState.studentClass, + exp = studentHomeMainState.studentExp + ) + Spacer(modifier = Modifier.weight(1f)) + Row { + ContentVerticalCard( + cardModifier = Modifier + .weight(1f) + .aspectRatio(1f) + .padding(end = 10.dp), + cardColor = Orange, + textColor = OrangeText, + imageDrawable = UlbanRes.drawable.announce, + text = stringResource(id = R.string.student_home_main_announce), + onClick = announceCardOnClick + ) + ContentVerticalCard( + cardModifier = Modifier + .weight(1f) + .aspectRatio(1f) + .padding(start = 10.dp), + cardColor = Blue, + textColor = BlueText, + imageDrawable = UlbanRes.drawable.tag_hello, + text = stringResource(id = R.string.student_home_main_hi), + onClick = showGreetingDialog + ) + } + Spacer(modifier = Modifier.height(20.dp)) + Text( + text = stringResource(id = R.string.student_home_main_best_friends), + style = UlbanTypography.titleSmall, + modifier = Modifier.padding(start = 15.dp) + ) + Spacer(modifier = Modifier.height(10.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(), + horizontalArrangement = Arrangement.Center + ) { + studentHomeMainState.bestFriendList.take(3).forEach { + Spacer(modifier = Modifier.width(10.dp)) + StudentSimpleCardItem( + modifier = modifier + .height(130.dp) + .width(100.dp), + name = it.memberSimple.name, + photo = it.memberSimple.photo, + score = it.relationPoint + ) + Spacer(modifier = Modifier.width(10.dp)) + } + } + Spacer(modifier = Modifier.height(20.dp)) + Row { + ContentVerticalCard( + cardModifier = Modifier + .weight(1f) + .aspectRatio(1f) + .padding(end = 10.dp), + cardColor = Purple, + textColor = PurpleText, + imageDrawable = UlbanRes.drawable.chat, + text = stringResource(id = R.string.student_home_main_chatting), + onClick = chattingCardOnClick + ) + ContentVerticalCard( + cardModifier = Modifier + .weight(1f) + .aspectRatio(1f) + .padding(start = 10.dp), + cardColor = Yellow, + textColor = YellowText, + imageDrawable = UlbanRes.drawable.rank, + text = stringResource(id = R.string.student_home_main_rank), + onClick = rankCardOnClick + ) + } + Spacer(modifier = Modifier.weight(1f)) + } +} + +@Preview(showBackground = true) +@Composable +fun StudentHomeMainScreenPreview() { + StudentHomeMainScreen( + studentHomeMainState = StudentHomeMainState( + studentName = "홍유준", + studentImageUrl = "https://www.google.com", + studentClass = "구미초등학교 1학년 1반", + studentExp = 2340, + bestFriendList = listOf( + MemberSimpleWithScore( + memberSimple = MemberSimple( + name = "김철수", + photo = "https://www.google.com" + ), + relationPoint = 234 + ), + MemberSimpleWithScore( + memberSimple = MemberSimple( + name = "김영희", + photo = "https://www.google.com" + ), + relationPoint = 234 + ), + MemberSimpleWithScore( + memberSimple = MemberSimple( + name = "박영수", + photo = "https://www.google.com" + ), + relationPoint = 234 + ) + ) + ) + ) +} \ No newline at end of file diff --git a/android/feature/student/home/src/main/java/com/sixkids/student/home/main/StudentHomeMainViewModel.kt b/android/feature/student/home/src/main/java/com/sixkids/student/home/main/StudentHomeMainViewModel.kt new file mode 100644 index 00000000..4706638c --- /dev/null +++ b/android/feature/student/home/src/main/java/com/sixkids/student/home/main/StudentHomeMainViewModel.kt @@ -0,0 +1,65 @@ +package com.sixkids.student.home.main + +import android.util.Log +import androidx.lifecycle.viewModelScope +import com.sixkids.domain.usecase.organization.GetSelectedOrganizationIdUseCase +import com.sixkids.domain.usecase.user.GetStudentHomeInfoUseCase +import com.sixkids.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +private const val TAG = "D107" +@HiltViewModel +class StudentHomeMainViewModel @Inject constructor( + private val getSelectedOrganizationIdUseCase: GetSelectedOrganizationIdUseCase, + private val getStudentHomeInfoUseCase: GetStudentHomeInfoUseCase +) : BaseViewModel(StudentHomeMainState()) { + + private var organizationId: Long? = null + + fun getStudentHomeInfo() { + viewModelScope.launch { + intent { copy(isLoading = true) } + + if (organizationId == null) { + organizationId = getSelectedOrganizationIdUseCase().getOrNull()?.toLong() + } + + if (organizationId != null) { + getStudentHomeInfoUseCase(organizationId!!) + .onSuccess { info -> + intent { + copy( + studentName = info.name, + studentImageUrl = info.photo, + studentClass = info.className, + studentExp = info.exp, + bestFriendList = info.relations + ) + } + } + .onFailure { + postSideEffect( + StudentHomeMainEffect.onShowSnackBar( + it.message ?: "알 수 없는 오류가 발생했습니다." + ) + ) + } + } else { + postSideEffect(StudentHomeMainEffect.onShowSnackBar("학급 정보를 불러오지 못했어요 ;(")) + } + + + } + } + + fun showGreetingDialog(){ + intent { copy(isShowGreetingDialog = true) } + } + + fun offDialog(){ + Log.d(TAG, "offDialog: ") + intent { copy(isShowGreetingDialog = false) } + } +} \ No newline at end of file diff --git a/android/feature/student/home/src/main/java/com/sixkids/student/home/main/component/GreetingDialog.kt b/android/feature/student/home/src/main/java/com/sixkids/student/home/main/component/GreetingDialog.kt new file mode 100644 index 00000000..95f0a40b --- /dev/null +++ b/android/feature/student/home/src/main/java/com/sixkids/student/home/main/component/GreetingDialog.kt @@ -0,0 +1,121 @@ +package com.sixkids.student.home.main.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +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.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +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 coil.compose.AsyncImage +import com.sixkids.designsystem.component.dialog.UlbanBasicDialog +import com.sixkids.designsystem.theme.Blue +import com.sixkids.designsystem.theme.BlueDark +import com.sixkids.designsystem.theme.Red +import com.sixkids.designsystem.theme.RedDark +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.designsystem.R as DesignR + +@Composable +fun GreetingDialog( + senderClick: () -> Unit = {}, + receiverClick: () -> Unit = {}, + cancelClick: () -> Unit = {} +) { + UlbanBasicDialog { + + Column( + modifier = Modifier + .width(280.dp) + .padding(vertical = 16.dp, horizontal = 10.dp), + verticalArrangement = Arrangement.spacedBy(15.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + painter = painterResource(id = DesignR.drawable.ic_cancel), + contentDescription = "cancel", + modifier = Modifier.align(Alignment.End).size(32.dp).clickable { + cancelClick() + } + ) + + AsyncImage( + model = DesignR.drawable.tag_hello, + modifier = Modifier.fillMaxWidth(), + placeholder = painterResource(id = DesignR.drawable.relay_tag), + contentDescription = "tag" + ) + + Text( + text = "친구와 인사를 하고\n점수를 올려봐요", + style = UlbanTypography.titleSmall, + textAlign = TextAlign.Center + ) + + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Button( + modifier = Modifier + .weight(1f) + .aspectRatio(1f) + .clip(RoundedCornerShape(16.dp)) + .background(Red), + colors = ButtonDefaults.buttonColors(containerColor = Red), + onClick = { senderClick() }) { + Text( + text = "친구에게\n인사 건네기", + style = UlbanTypography.titleSmall.copy( + fontSize = 14.sp, + lineHeight = 24.sp, + color = RedDark + ), + textAlign = TextAlign.Center + ) + } + + Button( + modifier = Modifier + .weight(1f) + .aspectRatio(1f) + .clip(RoundedCornerShape(16.dp)) + .background(Blue), + colors = ButtonDefaults.buttonColors(containerColor = Blue), + onClick = { receiverClick() }) { + Text( + text = "친구의\n인사 받기", + style = UlbanTypography.titleSmall.copy( + fontSize = 14.sp, + lineHeight = 24.sp, + color = BlueDark + ), + textAlign = TextAlign.Center + ) + } + } + } + } + + +} + +@Composable +@Preview(showBackground = true) +fun GreetingDialogPreview() { + GreetingDialog() +} \ No newline at end of file diff --git a/android/feature/student/home/src/main/java/com/sixkids/student/home/main/component/StudentInfo.kt b/android/feature/student/home/src/main/java/com/sixkids/student/home/main/component/StudentInfo.kt new file mode 100644 index 00000000..dcf2365d --- /dev/null +++ b/android/feature/student/home/src/main/java/com/sixkids/student/home/main/component/StudentInfo.kt @@ -0,0 +1,90 @@ +package com.sixkids.student.home.main.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +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 coil.compose.AsyncImage +import com.sixkids.designsystem.theme.UlbanTypography + +@Preview(showBackground = true) +@Composable +fun StudentMainInfoCardPreview() { + StudentMainInfo( + name = "홍유준", + classString = "구미초등학교 1학년 1반", + exp = 2340, + ) +} + +@Composable +fun StudentMainInfo( + modifier: Modifier = Modifier, + name: String = "", + imageUrlString: String = "", + classString: String = "", + exp: Int = 0 +) { + val height = 80.dp + + Card( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight(), + colors = CardDefaults.cardColors( + containerColor = Color.Transparent + ) + ) { + Row { + Card { + AsyncImage( + model = imageUrlString, + contentDescription = null, + modifier = Modifier.size(height), + contentScale = ContentScale.Crop + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Column( + modifier = Modifier.height(height), + verticalArrangement = Arrangement.Center + ) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + textAlign = TextAlign.Start, + text = "$name 학생", + style = UlbanTypography.titleMedium + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + textAlign = TextAlign.Start, + text = classString.replace("\n"," "), + style = UlbanTypography.titleSmall + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + textAlign = TextAlign.Start, + text = "${exp}점", + style = UlbanTypography.bodyMedium + ) + Spacer(modifier = Modifier.height(4.dp)) + } + } + } +} \ No newline at end of file diff --git a/android/feature/student/home/src/main/java/com/sixkids/student/home/navigation/StudentHomeNavigation.kt b/android/feature/student/home/src/main/java/com/sixkids/student/home/navigation/StudentHomeNavigation.kt new file mode 100644 index 00000000..7ea79327 --- /dev/null +++ b/android/feature/student/home/src/main/java/com/sixkids/student/home/navigation/StudentHomeNavigation.kt @@ -0,0 +1,134 @@ +package com.sixkids.student.home.navigation + +import androidx.compose.foundation.layout.PaddingValues +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.NavType +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import com.sixkids.student.home.announce.announcedetail.StudentAnnounceDetailRoute +import com.sixkids.student.home.announce.announcelist.StudentAnnounceListRoute +import com.sixkids.student.home.chatting.StudentChattingRoute +import com.sixkids.student.home.greeting.receiver.GreetingReceiverRoute +import com.sixkids.student.home.greeting.sender.GreetingSenderRoute +import com.sixkids.student.home.main.StudentHomeMainRoute +import com.sixkids.student.home.rank.StudentRankRoute +import com.sixkids.ui.SnackbarToken + +fun NavController.navigateStudentHome(navOptions: NavOptions) { + navigate(StudentHomeRoute.defaultRoute,navOptions) +} + +fun NavController.navigateStudentAnnounceList() { + navigate(StudentHomeRoute.announceListRoute) +} + +fun NavController.navigateStudentAnnounceDetail(announceDetailId: Long) { + navigate(StudentHomeRoute.announceDetailRoute(announceDetailId)) +} + +fun NavController.navigateStudentChatting() { + navigate(StudentHomeRoute.chattingRoute) +} + +fun NavController.navigateStudentRank() { + navigate(StudentHomeRoute.rankRoute) +} + +fun NavController.navigateStudentGreetingSender() { + navigate(StudentHomeRoute.greetingSenderRoute) +} + +fun NavController.navigateStudentGreetingReceiver() { + navigate(StudentHomeRoute.greetingReceiverRoute) +} + +fun NavGraphBuilder.studentHomeNavGraph( + padding: PaddingValues, + onShowSnackbar: (SnackbarToken) -> Unit, + navigateBack: () -> Unit, + navigateToStudentAnnounceList: () -> Unit, + navigateToStudentAnnounceDetail: (Long) -> Unit, + navigateToTagHello: () -> Unit, + navigateToRank: () -> Unit, + navigateToChatting: () -> Unit, + navigateToGreetingSender: () -> Unit, + navigateToGreetingReceiver: () -> Unit, + onBackClick: () -> Unit +) { + composable(route = StudentHomeRoute.defaultRoute) { + StudentHomeMainRoute( + padding = padding, + navigateToAnnounce = navigateToStudentAnnounceList, + navigateToTagHello = navigateToTagHello, + navigateToRank = navigateToRank, + navigateToChatting = navigateToChatting, + navigateToGreetingSender = navigateToGreetingSender, + navigateToGreetingReceiver = navigateToGreetingReceiver, + onShowSnackBar = onShowSnackbar + ) + } + + composable(route = StudentHomeRoute.announceListRoute) { + StudentAnnounceListRoute( + padding = padding, + navigateToStudentAnnounceDetail = navigateToStudentAnnounceDetail, + onShowSnackBar = onShowSnackbar + ) + } + + composable( + route = StudentHomeRoute.announceDetailRoute, + arguments = listOf(navArgument(StudentHomeRoute.announceDetailARG) { type = NavType.LongType }) + ) { + StudentAnnounceDetailRoute( + padding = padding, + onShowSnackBar = onShowSnackbar + ) + } + + composable(route = StudentHomeRoute.chattingRoute) { + StudentChattingRoute( + onBackClick = navigateBack, + onShowSnackBar = onShowSnackbar + ) + } + + composable(route = StudentHomeRoute.rankRoute) { + StudentRankRoute( + padding = padding, + onShowSnackBar = onShowSnackbar + ) + } + + composable(route = StudentHomeRoute.greetingSenderRoute) { + GreetingSenderRoute( + onBackClick = navigateBack, + ) + } + + composable(route = StudentHomeRoute.greetingReceiverRoute) { + GreetingReceiverRoute( + onBackClick = navigateBack, + ) + } +} + +object StudentHomeRoute { + const val announceDetailARG = "announceDetailId" + + const val defaultRoute = "student_home" + + const val announceListRoute = "student_announce_list" + const val announceDetailRoute = "student_announce_detail/{$announceDetailARG}" + + const val chattingRoute = "student_chatting" + + const val rankRoute = "student_rank" + + const val greetingSenderRoute = "student_greeting_sender" + const val greetingReceiverRoute = "student_greeting_receiver" + + fun announceDetailRoute(announceDetailId: Long) = "student_announce_detail/$announceDetailId" +} \ No newline at end of file diff --git a/android/feature/student/home/src/main/java/com/sixkids/student/home/rank/StudentRankContract.kt b/android/feature/student/home/src/main/java/com/sixkids/student/home/rank/StudentRankContract.kt new file mode 100644 index 00000000..e129605c --- /dev/null +++ b/android/feature/student/home/src/main/java/com/sixkids/student/home/rank/StudentRankContract.kt @@ -0,0 +1,15 @@ +package com.sixkids.student.home.rank + +import com.sixkids.model.MemberRankItem +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState + +sealed interface StudentRankEffect : SideEffect { + data class onShowSnackBar(val message: String): StudentRankEffect +} + +data class StudentRankState( + val isLoading: Boolean = false, + val classString: String = "", + val rankList: List = emptyList(), +): UiState diff --git a/android/feature/student/home/src/main/java/com/sixkids/student/home/rank/StudentRankScreen.kt b/android/feature/student/home/src/main/java/com/sixkids/student/home/rank/StudentRankScreen.kt new file mode 100644 index 00000000..619edfef --- /dev/null +++ b/android/feature/student/home/src/main/java/com/sixkids/student/home/rank/StudentRankScreen.kt @@ -0,0 +1,166 @@ +package com.sixkids.student.home.rank + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.sixkids.designsystem.component.appbar.UlbanDetailAppBar +import com.sixkids.designsystem.component.screen.LoadingScreen +import com.sixkids.designsystem.theme.Yellow +import com.sixkids.model.MemberRankItem +import com.sixkids.student.home.R +import com.sixkids.student.home.rank.component.RankItem +import com.sixkids.student.home.rank.component.StudentRankViewModel +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.extension.collectWithLifecycle +import com.sixkids.designsystem.R as UlbanRes + +@Composable +fun StudentRankRoute( + viewModel: StudentRankViewModel = hiltViewModel(), + padding: PaddingValues, + onShowSnackBar: (SnackbarToken) -> Unit +) { + + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + viewModel.sideEffect.collectWithLifecycle { + when (it) { + is StudentRankEffect.onShowSnackBar -> onShowSnackBar(SnackbarToken(it.message)) + } + } + + LaunchedEffect(Unit) { + viewModel.getOrganizationName() + viewModel.getClassRank() + } + + Box( + modifier = Modifier + .padding(padding) + .fillMaxSize() + ) { + StudentRankScreen( + studentRankState = uiState + ) + if (uiState.isLoading) { + LoadingScreen() + } + } + +} + +@Composable +fun StudentRankScreen( + modifier: Modifier = Modifier, + studentRankState: StudentRankState = StudentRankState() +) { + Column( + modifier = modifier + .fillMaxSize() + ) { + UlbanDetailAppBar( + leftIcon = UlbanRes.drawable.rank, + title = stringResource(id = R.string.student_home_main_rank), + content = stringResource(id = R.string.student_home_main_rank), + topDescription = "", + bottomDescription = studentRankState.classString, + color = Yellow + ) + LazyColumn( + modifier = Modifier + .padding(16.dp) + ) { + items(studentRankState.rankList.size) { index -> + RankItem( + rank = studentRankState.rankList[index].rank, + name = studentRankState.rankList[index].name, + exp = studentRankState.rankList[index].exp + ) + if (index != studentRankState.rankList.size - 1) { + HorizontalDivider( + modifier = Modifier.padding(vertical = 4.dp), + color = Color.Black, + thickness = 2.dp + ) + } + } + } + } +} + + +@Preview(showBackground = true) +@Composable +fun RankScreenPreview() { + StudentRankScreen( + studentRankState = StudentRankState( + classString = "구미 초등학교 1학년 1반", + rankList = listOf( + MemberRankItem( + rank = 1, + name = "김철수", + exp = 100 + ), + MemberRankItem( + rank = 2, + name = "박영희", + exp = 90 + ), + MemberRankItem( + rank = 3, + name = "이영수", + exp = 80 + ), + MemberRankItem( + rank = 4, + name = "최영희", + exp = 70 + ), + MemberRankItem( + rank = 5, + name = "홍길동", + exp = 60 + ), + MemberRankItem( + rank = 6, + name = "김철수", + exp = 50 + ), + MemberRankItem( + rank = 7, + name = "박영희", + exp = 40 + ), + MemberRankItem( + rank = 8, + name = "이영수", + exp = 30 + ), + MemberRankItem( + rank = 9, + name = "최영희", + exp = 20 + ), + MemberRankItem( + rank = 10, + name = "홍길동", + exp = 10 + ) + ) + ) + ) +} \ No newline at end of file diff --git a/android/feature/student/home/src/main/java/com/sixkids/student/home/rank/component/RankItem.kt b/android/feature/student/home/src/main/java/com/sixkids/student/home/rank/component/RankItem.kt new file mode 100644 index 00000000..ac799c8d --- /dev/null +++ b/android/feature/student/home/src/main/java/com/sixkids/student/home/rank/component/RankItem.kt @@ -0,0 +1,90 @@ +package com.sixkids.student.home.rank.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.AbsoluteAlignment +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.designsystem.R as UlbanRes + +@Composable +fun RankItem( + modifier: Modifier = Modifier, + rank: Int = 0, + name: String = "이름", + exp: Int = 0, +) { + val rankHeight = 40 + Box( + modifier = modifier.fillMaxWidth(), + ) { + // Rank + when (rank) { + 1, 2, 3 -> { + Image( + modifier = Modifier + .height(rankHeight.dp) + .aspectRatio(1f) + .align(AbsoluteAlignment.CenterLeft), + painter = painterResource( + id = when (rank) { + 1 -> UlbanRes.drawable.rank_first + 2 -> UlbanRes.drawable.rank_second + 3 -> UlbanRes.drawable.rank_third + else -> UlbanRes.drawable.rank_third + }, + ), + contentDescription = null + ) + } + + else -> { + Box( + modifier = Modifier + .height(rankHeight.dp) + .align(Alignment.CenterStart), + ){ + Text( + modifier = Modifier.align(Alignment.Center), + text = "${rank}등", + style = UlbanTypography.titleMedium, + textAlign = TextAlign.Center, + ) + } + } + } + // Name + Text( + modifier = Modifier.align(Alignment.Center), + text = name, + style = UlbanTypography.titleMedium, + maxLines = 1 + ) + // Exp + Text( + modifier = Modifier.align(Alignment.CenterEnd), + text = "${exp}exp", + style = UlbanTypography.titleMedium, + maxLines = 1, + + ) + } +} + +@Preview(showBackground = true) +@Composable +fun RankItemPreview() { + RankItem( + rank = 4 + ) +} \ No newline at end of file diff --git a/android/feature/student/home/src/main/java/com/sixkids/student/home/rank/component/StudentRankViewModel.kt b/android/feature/student/home/src/main/java/com/sixkids/student/home/rank/component/StudentRankViewModel.kt new file mode 100644 index 00000000..bdf95f1e --- /dev/null +++ b/android/feature/student/home/src/main/java/com/sixkids/student/home/rank/component/StudentRankViewModel.kt @@ -0,0 +1,53 @@ +package com.sixkids.student.home.rank.component + +import androidx.lifecycle.viewModelScope +import com.sixkids.domain.usecase.organization.GetClassRankUseCase +import com.sixkids.domain.usecase.organization.GetSelectedOrganizationIdUseCase +import com.sixkids.domain.usecase.organization.LoadSelectedOrganizationNameUseCase +import com.sixkids.student.home.rank.StudentRankEffect +import com.sixkids.student.home.rank.StudentRankState +import com.sixkids.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class StudentRankViewModel @Inject constructor( + private val getSelectedOrganizationIdUseCase: GetSelectedOrganizationIdUseCase, + private val loadSelectedOrganizationNameUseCase: LoadSelectedOrganizationNameUseCase, + private val getClassRankUseCase: GetClassRankUseCase +): BaseViewModel(StudentRankState()){ + private var organizationId: Int? = null + + private suspend fun getOrganizationId() { + organizationId = getSelectedOrganizationIdUseCase().getOrNull() + } + + fun getOrganizationName() { + viewModelScope.launch { + loadSelectedOrganizationNameUseCase().onSuccess { + intent { copy(classString = it) } + } + } + } + + fun getClassRank() { + viewModelScope.launch { + if (organizationId == null) { + getOrganizationId() + } + + if (organizationId != null) { + val result = getClassRankUseCase(organizationId!!) + result.onSuccess { + intent { copy(rankList = it) } + } + result.onFailure { + postSideEffect(StudentRankEffect.onShowSnackBar(it.message ?: "학급 랭킹 불러오기에 실패했습니다 ;(")) + } + } else { + postSideEffect(StudentRankEffect.onShowSnackBar("학급 정보를 불러오는데 실패했습니다 ;(")) + } + } + } +} \ No newline at end of file diff --git a/android/feature/student/home/src/main/res/drawable/ic_camera.xml b/android/feature/student/home/src/main/res/drawable/ic_camera.xml new file mode 100644 index 00000000..7283b1a9 --- /dev/null +++ b/android/feature/student/home/src/main/res/drawable/ic_camera.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/feature/student/home/src/main/res/values/strings.xml b/android/feature/student/home/src/main/res/values/strings.xml new file mode 100644 index 00000000..414f7f59 --- /dev/null +++ b/android/feature/student/home/src/main/res/values/strings.xml @@ -0,0 +1,12 @@ + + + 인사 + 알림장 + 채팅 + 랭킹 + 친한 친구들 + + 알림장이 없어용! + + 댓글을 입력하세요 + \ No newline at end of file diff --git a/android/feature/student/main/.gitignore b/android/feature/student/main/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/android/feature/student/main/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/android/feature/student/main/build.gradle.kts b/android/feature/student/main/build.gradle.kts new file mode 100644 index 00000000..1402795a --- /dev/null +++ b/android/feature/student/main/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + alias(libs.plugins.sixkids.android.feature.compose) +} + +android { + namespace = "com.sixkids.student.main" +} + +dependencies { + implementation(libs.accompanist.pager) + implementation(platform(libs.firebase.bom)) + implementation(libs.bundles.firebase) +} diff --git a/android/feature/student/main/consumer-rules.pro b/android/feature/student/main/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/android/feature/student/main/proguard-rules.pro b/android/feature/student/main/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/android/feature/student/main/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/android/data/src/androidTest/java/com/sixkids/data/ExampleInstrumentedTest.kt b/android/feature/student/main/src/androidTest/java/com/sixkids/student/main/ExampleInstrumentedTest.kt similarity index 83% rename from android/data/src/androidTest/java/com/sixkids/data/ExampleInstrumentedTest.kt rename to android/feature/student/main/src/androidTest/java/com/sixkids/student/main/ExampleInstrumentedTest.kt index 781e9e19..3f1c064f 100644 --- a/android/data/src/androidTest/java/com/sixkids/data/ExampleInstrumentedTest.kt +++ b/android/feature/student/main/src/androidTest/java/com/sixkids/student/main/ExampleInstrumentedTest.kt @@ -1,4 +1,4 @@ -package com.sixkids.data +package com.sixkids.student.main import androidx.test.platform.app.InstrumentationRegistry import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -19,6 +19,6 @@ class ExampleInstrumentedTest { fun useAppContext() { // Context of the app under test. val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.sixkids.data.test", appContext.packageName) + assertEquals("com.sixkids.student.main.test", appContext.packageName) } -} +} \ No newline at end of file diff --git a/android/feature/student/main/src/main/AndroidManifest.xml b/android/feature/student/main/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a5918e68 --- /dev/null +++ b/android/feature/student/main/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/android/feature/student/main/src/main/java/com/sixkids/student/main/joinorganization/JoinOrganizationContract.kt b/android/feature/student/main/src/main/java/com/sixkids/student/main/joinorganization/JoinOrganizationContract.kt new file mode 100644 index 00000000..447ef8c5 --- /dev/null +++ b/android/feature/student/main/src/main/java/com/sixkids/student/main/joinorganization/JoinOrganizationContract.kt @@ -0,0 +1,16 @@ +package com.sixkids.student.main.joinorganization + +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState + +sealed interface JoinOrganizationEffect : SideEffect { + data object NavigateToOrganizationList : JoinOrganizationEffect + data class OnShowSnackBar(val tkn: SnackbarToken) : JoinOrganizationEffect +} + +data class JoinOrganizationState( + val isLoading: Boolean = false, + val code: String = "", + val id: String = "", +) : UiState \ No newline at end of file diff --git a/android/feature/student/main/src/main/java/com/sixkids/student/main/joinorganization/JoinOrganizationScreen.kt b/android/feature/student/main/src/main/java/com/sixkids/student/main/joinorganization/JoinOrganizationScreen.kt new file mode 100644 index 00000000..753075a5 --- /dev/null +++ b/android/feature/student/main/src/main/java/com/sixkids/student/main/joinorganization/JoinOrganizationScreen.kt @@ -0,0 +1,125 @@ +package com.sixkids.student.main.joinorganization + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.sixkids.designsystem.component.button.UlbanFilledButton +import com.sixkids.designsystem.component.screen.UlbanTopSection +import com.sixkids.designsystem.component.textfield.UlbanUnderLineTextField +import com.sixkids.designsystem.theme.UlbanTheme +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.student.main.R +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.extension.collectWithLifecycle + +@Composable +fun JoinOrganizationRoute( + viewModel: JoinOrganizationViewModel = hiltViewModel(), + navigateToStudentOrganizationList: () -> Unit, + onBackClick: () -> Unit, + onShowSnackBar: (SnackbarToken) -> Unit, +) { + val uiState = viewModel.uiState.collectAsStateWithLifecycle().value + + viewModel.sideEffect.collectWithLifecycle { sideEffect -> + when (sideEffect) { + JoinOrganizationEffect.NavigateToOrganizationList -> navigateToStudentOrganizationList() + is JoinOrganizationEffect.OnShowSnackBar -> onShowSnackBar(sideEffect.tkn) + } + } + + JoinOrganizationScreen( + uiState = uiState, + onJoinOrganizationClick = viewModel::joinOrganizationClick, + onBackClick = onBackClick, + onUpdateCode = viewModel::updateCode, + onUpdateId = viewModel::updateId + ) +} + +@Composable +fun JoinOrganizationScreen( + paddingValues: PaddingValues = PaddingValues(20.dp), + uiState: JoinOrganizationState = JoinOrganizationState(), + onJoinOrganizationClick: () -> Unit = {}, + onBackClick: () -> Unit = {}, + onUpdateCode: (String) -> Unit = {}, + onUpdateId: (String) -> Unit = {}, +){ + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + Column( + modifier = Modifier + .fillMaxSize(), + horizontalAlignment = Alignment.Start, + ) { + UlbanTopSection(stringResource(R.string.join_organization_top), onBackClick) + + Spacer(modifier = Modifier.height(36.dp)) + + Text( + text = stringResource(id = R.string.join_organization_title), + style = UlbanTypography.bodyLarge, + modifier = Modifier.padding(top = 10.dp, bottom = 10.dp) + ) + + UlbanUnderLineTextField( + text = uiState.code, + hint = stringResource(R.string.join_organization_code_hint), + onTextChange = onUpdateCode, + onIconClick = { + onUpdateCode("") + } + ) + + Spacer(modifier = Modifier.height(10.dp)) + + Text( + text = stringResource(id = R.string.join_organization_id), + style = UlbanTypography.bodyLarge, + modifier = Modifier.padding(top = 10.dp, bottom = 10.dp) + ) + + UlbanUnderLineTextField( + text = uiState.id, + hint = stringResource(R.string.join_organization_id_hint), + onTextChange = onUpdateId, + onIconClick = { + onUpdateId("") + } + ) + + Spacer(modifier = Modifier.weight(1f)) + + UlbanFilledButton( + text = stringResource(R.string.profile_done), + onClick = { onJoinOrganizationClick() }, + modifier = Modifier.fillMaxWidth()) + } + } +} + +@Composable +@Preview(showBackground = true) +fun JoinOrganizationScreenPreview() { + UlbanTheme { + JoinOrganizationScreen() + } +} \ No newline at end of file diff --git a/android/feature/student/main/src/main/java/com/sixkids/student/main/joinorganization/JoinOrganizationViewModel.kt b/android/feature/student/main/src/main/java/com/sixkids/student/main/joinorganization/JoinOrganizationViewModel.kt new file mode 100644 index 00000000..83a7b687 --- /dev/null +++ b/android/feature/student/main/src/main/java/com/sixkids/student/main/joinorganization/JoinOrganizationViewModel.kt @@ -0,0 +1,36 @@ +package com.sixkids.student.main.joinorganization + +import androidx.lifecycle.viewModelScope +import com.sixkids.domain.usecase.organization.JoinOrganizationUseCase +import com.sixkids.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class JoinOrganizationViewModel @Inject constructor( + private val joinOrganizationUseCase: JoinOrganizationUseCase +): BaseViewModel(JoinOrganizationState()){ + + fun updateCode(code: String){ + intent { copy(code = code) } + } + + fun updateId(id: String){ + intent { copy(id = id) } + } + + fun joinOrganizationClick(){ + viewModelScope.launch { + intent { copy(isLoading = true) } + joinOrganizationUseCase(uiState.value.id.toInt(), uiState.value.code) + .onSuccess { + if (it>0){ + postSideEffect(JoinOrganizationEffect.NavigateToOrganizationList) + } + }.onFailure { + + } + } + } +} \ No newline at end of file diff --git a/android/feature/student/main/src/main/java/com/sixkids/student/main/navigation/StudentMainNavigation.kt b/android/feature/student/main/src/main/java/com/sixkids/student/main/navigation/StudentMainNavigation.kt new file mode 100644 index 00000000..250a0a4c --- /dev/null +++ b/android/feature/student/main/src/main/java/com/sixkids/student/main/navigation/StudentMainNavigation.kt @@ -0,0 +1,62 @@ +package com.sixkids.student.main.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.sixkids.student.main.joinorganization.JoinOrganizationRoute +import com.sixkids.student.main.organization.StudentOrganizationListRoute +import com.sixkids.student.main.profile.StudentProfileRoute +import com.sixkids.ui.SnackbarToken + +fun NavController.navigateStudentOrganizationList() { + navigate(StudentMainRoute.defaultRoute) +} + +fun NavController.navigateStudentProfile(){ + navigate(StudentMainRoute.profileRoute) +} + +fun NavController.navigateJoinOrganization(){ + navigate(StudentMainRoute.joinOrganizationRoute) +} + +fun NavGraphBuilder.studentOrganizationListNavGraph( + navigateToJoinOrganization: () -> Unit, + navigateToProfile: () -> Unit, + navigateToHome: () -> Unit, + navigateToSignIn: () -> Unit, + onShowSnackBar: (SnackbarToken) -> Unit, + onBackClick: () -> Unit +) { + composable(route = StudentMainRoute.defaultRoute) { + StudentOrganizationListRoute( + navigateToJoinOrganization = navigateToJoinOrganization, + navigateToProfile = navigateToProfile, + navigateToHome = navigateToHome, + onShowSnackBar = onShowSnackBar + ) + } + + composable(route = StudentMainRoute.joinOrganizationRoute) { + JoinOrganizationRoute( + navigateToStudentOrganizationList = onBackClick, + onBackClick = onBackClick, + onShowSnackBar = onShowSnackBar + ) + } + + composable(route = StudentMainRoute.profileRoute) { + StudentProfileRoute( + navigateToSignIn = navigateToSignIn, + navigateToStudentOrganizationList = onBackClick, + onBackClick = onBackClick, + onShowSnackBar = onShowSnackBar + ) + } +} + +object StudentMainRoute { + const val defaultRoute = "student-organization-list" + const val joinOrganizationRoute = "join-organization" + const val profileRoute = "student-profile" +} \ No newline at end of file diff --git a/android/feature/student/main/src/main/java/com/sixkids/student/main/organization/OrganizationContract.kt b/android/feature/student/main/src/main/java/com/sixkids/student/main/organization/OrganizationContract.kt new file mode 100644 index 00000000..ffa85a2d --- /dev/null +++ b/android/feature/student/main/src/main/java/com/sixkids/student/main/organization/OrganizationContract.kt @@ -0,0 +1,21 @@ +package com.sixkids.student.main.organization + +import com.sixkids.model.Organization +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState + + +sealed interface OrganizationListEffect : SideEffect { + data object NavigateToJoinClass : OrganizationListEffect + data object NavigateToProfile : OrganizationListEffect + data object NavigateToHome : OrganizationListEffect + data class OnShowSnackBar(val tkn: SnackbarToken) : OrganizationListEffect +} + +data class OrganizationListState( + val isLoading: Boolean = false, + val name: String = "", + val profilePhoto: String = "", + val organizationList: List = emptyList(), +) : UiState \ No newline at end of file diff --git a/android/feature/student/main/src/main/java/com/sixkids/student/main/organization/OrganizationScreen.kt b/android/feature/student/main/src/main/java/com/sixkids/student/main/organization/OrganizationScreen.kt new file mode 100644 index 00000000..ee864a5c --- /dev/null +++ b/android/feature/student/main/src/main/java/com/sixkids/student/main/organization/OrganizationScreen.kt @@ -0,0 +1,299 @@ +package com.sixkids.student.main.organization + + +import android.util.Log +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +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.PaddingValues +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.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AccountCircle +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil.compose.AsyncImage +import com.google.android.gms.tasks.OnCompleteListener +import com.google.firebase.messaging.FirebaseMessaging +import com.sixkids.designsystem.component.screen.LoadingScreen +import com.sixkids.designsystem.theme.Blue +import com.sixkids.designsystem.theme.BlueDark +import com.sixkids.designsystem.theme.Green +import com.sixkids.designsystem.theme.Orange +import com.sixkids.designsystem.theme.Purple +import com.sixkids.designsystem.theme.Red +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.designsystem.theme.Yellow +import com.sixkids.model.Organization +import com.sixkids.student.main.R +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.extension.collectWithLifecycle +import kotlin.math.absoluteValue + +private const val TAG = "D107" +@Composable +fun StudentOrganizationListRoute( + viewModel: OrganizationViewModel = hiltViewModel(), + navigateToJoinOrganization: () -> Unit, + navigateToProfile: () -> Unit, + navigateToHome: () -> Unit, + onShowSnackBar: (SnackbarToken) -> Unit +) { + val uiState = viewModel.uiState.collectAsStateWithLifecycle().value + + viewModel.sideEffect.collectWithLifecycle { sideEffect -> + when (sideEffect) { + OrganizationListEffect.NavigateToJoinClass -> navigateToJoinOrganization() + OrganizationListEffect.NavigateToProfile -> navigateToProfile() + OrganizationListEffect.NavigateToHome -> navigateToHome() + is OrganizationListEffect.OnShowSnackBar -> onShowSnackBar(sideEffect.tkn) + } + } + + LaunchedEffect(key1 = Unit) { + viewModel.initData() + } + + OrganizationListScreen( + uiState = uiState, + onJoinClassClick = viewModel::joinOrganizationClick, + onProfileClick = viewModel::profileClick, + onClassClick = { classId -> + viewModel.organizationClick(classId) + } + ) + + + + FirebaseMessaging.getInstance().token.addOnCompleteListener( + OnCompleteListener { task -> + if (!task.isSuccessful) { + return@OnCompleteListener + } + if (task.result != null) { + viewModel.onTokenRefresh(task.result) + } + }, + ) + +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun OrganizationListScreen( + uiState: OrganizationListState = OrganizationListState(), + onJoinClassClick: () -> Unit = {}, + onProfileClick: () -> Unit = {}, + onClassClick: (Int) -> Unit = {} +) { + Box(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + val pagerState = rememberPagerState(pageCount = { uiState.organizationList.size }) + Icon( + imageVector = Icons.Outlined.AccountCircle, + contentDescription = "profile", + modifier = Modifier + .padding(24.dp) + .size(40.dp) + .align(Alignment.End) + .clickable { onProfileClick() }, + ) + + StudentUserInfoSection(name = uiState.name, photo = uiState.profilePhoto) + + OrganizationListSection( + pagerState = pagerState, + organizationList = uiState.organizationList, + onClassClick = onClassClick + ) + + Spacer(modifier = Modifier.weight(1f)) + + NewClassButton( + Modifier + .padding(18.dp, 28.dp) + .align(Alignment.End), + onNewClassClick = onJoinClassClick + ) + + } + if (uiState.isLoading) { + LoadingScreen() + } + } + +} + +@Composable +fun StudentUserInfoSection(name: String, photo: String) { + Log.d(TAG, "UserInfoSection: ") + Column { + AsyncImage( + model = photo, + contentDescription = "profile image", + modifier = Modifier + .padding(20.dp) + .size(200.dp) + .clip(RoundedCornerShape(16.dp)) + .align(Alignment.CenterHorizontally), + contentScale = ContentScale.Crop + ) + + Text( + text = String.format(stringResource(id = R.string.student_organization_welcome), name).also { + Log.d(TAG, "UserInfoSection: $it") + }, + style = UlbanTypography.titleMedium, + modifier = Modifier + .padding(0.dp, 0.dp, 0.dp, 60.dp) + .align(Alignment.CenterHorizontally) + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun OrganizationListSection( + pagerState: PagerState, + organizationList: List, + onClassClick: (Int) -> Unit +) { + val backgroundColorList = listOf(Red, Blue, Orange, Yellow, Green, Purple) + + val screenWidthDp = with(LocalDensity.current) { + LocalContext.current.resources.displayMetrics.widthPixels.toDp() + } + val cardWidth = 220.dp + val horizontalPadding = (screenWidthDp - cardWidth) / 2 + + if (organizationList.isEmpty()) { + Text( + text = stringResource(id = R.string.student_organization_no_organization), + style = UlbanTypography.titleMedium, + ) + } else { + + HorizontalPager( + pageSpacing = 10.dp, + state = pagerState, + contentPadding = PaddingValues(horizontal = horizontalPadding), + modifier = Modifier.fillMaxWidth() + ) { + val item = organizationList[it] + val name = item.name.split("\n") + Card( + modifier = Modifier + .padding(10.dp) + .size(cardWidth) + .clickable { onClassClick(item.id) } + .graphicsLayer { + val pageOffset = ( + (pagerState.currentPage - it) + pagerState + .currentPageOffsetFraction + ).absoluteValue + + alpha = lerp( + start = 0.5f, + stop = 1f, + fraction = 1f - pageOffset.coerceIn(0f, 1f) + ) + }, + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = backgroundColorList[it % backgroundColorList.size]), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Column( + modifier = Modifier + .padding(20.dp, 40.dp) + .fillMaxSize(), + verticalArrangement = Arrangement.SpaceBetween, + horizontalAlignment = Alignment.Start + ) { + Text(text = name[0], style = UlbanTypography.titleMedium) + Text( + text = name[1], + style = UlbanTypography.titleMedium.copy(fontWeight = FontWeight.SemiBold) + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Image( + painter = painterResource(id = com.sixkids.designsystem.R.drawable.member), + contentDescription = "member count" + ) + + Text( + text = "${item.memberCount}명", + style = UlbanTypography.bodyLarge.copy(fontWeight = FontWeight.SemiBold) + ) + } + } + } + } + } +} + +@Composable +fun NewClassButton( + modifier: Modifier = Modifier, + onNewClassClick: () -> Unit +) { + Button( + onClick = { onNewClassClick() }, + modifier = modifier, + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.buttonColors(Blue, contentColor = BlueDark), + elevation = ButtonDefaults.buttonElevation(defaultElevation = 4.dp, pressedElevation = 8.dp) + ) { + Row(modifier = Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(imageVector = Icons.Outlined.Add, contentDescription = "new class") + Spacer(modifier = Modifier.width(2.dp)) + Text( + text = stringResource(id = R.string.student_organization_join_class), + style = UlbanTypography.bodyMedium.copy(fontWeight = FontWeight.SemiBold) + ) + } + } +} + +@Composable +@Preview(showBackground = true) +fun OrganizationListScreenPreview() { + OrganizationListScreen() +} diff --git a/android/feature/student/main/src/main/java/com/sixkids/student/main/organization/OrganizationViewModel.kt b/android/feature/student/main/src/main/java/com/sixkids/student/main/organization/OrganizationViewModel.kt new file mode 100644 index 00000000..2838d1f4 --- /dev/null +++ b/android/feature/student/main/src/main/java/com/sixkids/student/main/organization/OrganizationViewModel.kt @@ -0,0 +1,76 @@ +package com.sixkids.student.main.organization + +import android.util.Log +import androidx.lifecycle.viewModelScope +import com.sixkids.domain.usecase.organization.GetOrganizationListUseCase +import com.sixkids.domain.usecase.organization.SaveSelectedOrganizationIdUseCase +import com.sixkids.domain.usecase.organization.SaveSelectedOrganizationNameUseCase +import com.sixkids.domain.usecase.user.GetUserInfoUseCase +import com.sixkids.domain.usecase.user.UpdateFCMTokenUseCase +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import javax.inject.Inject + +private const val TAG = "D107" +@HiltViewModel +class OrganizationViewModel @Inject constructor( + private val getUserInfoUseCase: GetUserInfoUseCase, + private val getOrganizationListUseCase: GetOrganizationListUseCase, + private val saveSelectedOrganizationIdUseCase: SaveSelectedOrganizationIdUseCase, + private val saveSelectedOrganizationNameUseCase: SaveSelectedOrganizationNameUseCase, + private val updateFCMTokenUseCase: UpdateFCMTokenUseCase +) : BaseViewModel(OrganizationListState()){ + + fun initData() { + viewModelScope.launch { + intent { copy(isLoading = true) } + + val userInfoJob = async { getUserInfoUseCase() } + val organizationListJob = async { getOrganizationListUseCase() } + + val userInfoResult = userInfoJob.await() + .onSuccess { + intent { copy(name = it.name, profilePhoto = it.photo) } + }.onFailure { + postSideEffect(OrganizationListEffect.OnShowSnackBar(SnackbarToken(message = it.message ?: "알 수 없는 오류가 발생했습니다."))) + } + val organizationListResult = organizationListJob.await() + .onSuccess { + intent { copy(organizationList = it) } + }.onFailure { + postSideEffect(OrganizationListEffect.OnShowSnackBar(SnackbarToken(message = it.message ?: "알 수 없는 오류가 발생했습니다."))) + } + intent { copy(isLoading = false) } + } + } + + fun joinOrganizationClick(){ + postSideEffect(OrganizationListEffect.NavigateToJoinClass) + } + + fun profileClick(){ + postSideEffect(OrganizationListEffect.NavigateToProfile) + } + + fun organizationClick(id: Int){ + viewModelScope.launch { + saveSelectedOrganizationIdUseCase(id) + currentState.organizationList.find { it.id == id }?.let { + saveSelectedOrganizationNameUseCase(it.name) + } + postSideEffect(OrganizationListEffect.NavigateToHome) + } + + } + + fun onTokenRefresh(fcmToken: String) { + viewModelScope.launch { + updateFCMTokenUseCase(fcmToken).onFailure { + Log.d(TAG, "onTokenRefresh: 토큰 갱신 실패 ${it.message}") + } + } + } +} \ No newline at end of file diff --git a/android/feature/student/main/src/main/java/com/sixkids/student/main/profile/ProfileContract.kt b/android/feature/student/main/src/main/java/com/sixkids/student/main/profile/ProfileContract.kt new file mode 100644 index 00000000..eb8cb1f5 --- /dev/null +++ b/android/feature/student/main/src/main/java/com/sixkids/student/main/profile/ProfileContract.kt @@ -0,0 +1,26 @@ +package com.sixkids.student.main.profile + +import android.graphics.Bitmap +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState + +sealed interface ProfileEffect : SideEffect { + data object NavigateToSignIn : ProfileEffect + data object NavigateToOrganizationList : ProfileEffect + data class OnShowSnackBar(val tkn: SnackbarToken) : ProfileEffect +} + +data class ProfileState( + val isLoading: Boolean = false, + val name: String = "", + val gender: Gender? = null, + val originalProfilePhoto: String? = null, + val changedProfileDefaultPhoto: Int? = null, + val changedProfileUserPhoto: Bitmap? = null +) : UiState + +enum class Gender{ + MAN, + WOMAN +} \ No newline at end of file diff --git a/android/feature/student/main/src/main/java/com/sixkids/student/main/profile/ProfileScreen.kt b/android/feature/student/main/src/main/java/com/sixkids/student/main/profile/ProfileScreen.kt new file mode 100644 index 00000000..0d64e1ff --- /dev/null +++ b/android/feature/student/main/src/main/java/com/sixkids/student/main/profile/ProfileScreen.kt @@ -0,0 +1,356 @@ +package com.sixkids.student.main.profile + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.ImageDecoder +import android.os.Build +import android.provider.MediaStore +import android.util.Log +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +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.aspectRatio +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.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil.compose.AsyncImage +import com.sixkids.designsystem.R as DesignSystemR +import com.sixkids.designsystem.component.button.UlbanFilledButton +import com.sixkids.designsystem.component.screen.LoadingScreen +import com.sixkids.designsystem.component.screen.UlbanTopSection +import com.sixkids.designsystem.theme.Cream +import com.sixkids.designsystem.theme.Red +import com.sixkids.designsystem.theme.RedDark +import com.sixkids.designsystem.theme.UlbanTheme +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.student.main.R +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.extension.collectWithLifecycle +import java.io.File +import java.io.FileOutputStream +import java.io.IOException + +private const val TAG = "D107" + +@Composable +fun StudentProfileRoute( + viewModel: ProfileViewModel = hiltViewModel(), + navigateToStudentOrganizationList: () -> Unit, + navigateToSignIn: () -> Unit, + onShowSnackBar: (SnackbarToken) -> Unit, + onBackClick: () -> Unit +) { + val context = LocalContext.current + val uiState = viewModel.uiState.collectAsStateWithLifecycle().value + + LaunchedEffect(key1 = Unit) { + viewModel.initData() + } + + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickVisualMedia() + ) { uri -> + uri?.let { + try { + val bitmap = if (Build.VERSION.SDK_INT < 28) { + MediaStore.Images.Media.getBitmap(context.contentResolver, it) + } else { + ImageDecoder.decodeBitmap( + ImageDecoder.createSource( + context.contentResolver, + it + ) + ) + } + viewModel.onProfilePhotoSelected(bitmap) + } catch (e: IOException) { + Log.e(TAG, "Error decoding bitmap", e) + } + } + } + + viewModel.sideEffect.collectWithLifecycle { + when (it) { + ProfileEffect.NavigateToOrganizationList -> navigateToStudentOrganizationList() + is ProfileEffect.OnShowSnackBar -> onShowSnackBar(it.tkn) + ProfileEffect.NavigateToSignIn -> navigateToSignIn() + } + } + + StudentProfileScreen( + uiState = uiState, + onClickPhoto = { resId -> + when (resId) { + DesignSystemR.drawable.camera -> + launcher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) + + DesignSystemR.drawable.teacher_man -> + viewModel.onProfileDefaultPhotoSelected(resId, Gender.MAN) + + DesignSystemR.drawable.teacher_woman -> + viewModel.onProfileDefaultPhotoSelected(resId, Gender.WOMAN) + } + }, + onDoneClick = { + viewModel.onChangeDoneClick( + saveBitmapToFile(context, uiState.changedProfileUserPhoto, "profile.jpg") + ) + }, + onSignOutClick = viewModel::onSignOutClick, + onBackClick = onBackClick + ) + +} + +@Composable +fun StudentProfileScreen( + uiState: ProfileState = ProfileState(), + onClickPhoto: (Int) -> Unit = {}, + onDoneClick: () -> Unit = {}, + onSignOutClick: () -> Unit = {}, + onBackClick: () -> Unit = {} +) { + val imageMan = DesignSystemR.drawable.student_boy + val imageWoman = DesignSystemR.drawable.student_girl + + Box(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(21.dp) + ) { + UlbanTopSection( + stringResource(id = R.string.student_profile_welcome, uiState.name), + onBackClick + ) + + Spacer(modifier = Modifier.height(60.dp)) + + SelectedPhotoCard( + uiState.changedProfileDefaultPhoto, + uiState.originalProfilePhoto, + uiState.changedProfileUserPhoto, + modifier = Modifier + .padding(10.dp) + .size(180.dp) + .align(Alignment.CenterHorizontally), + ) + + Spacer(modifier = Modifier.height(60.dp)) + + Row { + PhotoCard( + modifier = Modifier + .padding(10.dp) + .weight(1f) + .aspectRatio(1f), + img = imageMan, + onClickPhoto = onClickPhoto + ) + + PhotoCard( + modifier = Modifier + .padding(10.dp) + .weight(1f) + .aspectRatio(1f), + img = imageWoman, + onClickPhoto = onClickPhoto + ) + + PhotoCard( + modifier = Modifier + .padding(10.dp) + .weight(1f) + .aspectRatio(1f), + img = DesignSystemR.drawable.camera, + onClickPhoto = onClickPhoto + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + BottomSection(onDoneClick = onDoneClick, onSignOutClick = onSignOutClick) + + } + if (uiState.isLoading) { + LoadingScreen() + } + } +} + +@Composable +fun BottomSection( + onDoneClick: () -> Unit, + onSignOutClick: () -> Unit, + onExitClick: () -> Unit = { } +) { + Column { + UlbanFilledButton( + text = stringResource(id = R.string.profile_done), + onClick = onDoneClick, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(4.dp)) + + UlbanFilledButton( + text = stringResource(id = R.string.profile_sign_out), + onClick = onSignOutClick, + modifier = Modifier.fillMaxWidth(), + color = Red, + textColor = RedDark + ) + + Text( + text = stringResource(id = R.string.profile_exit), + style = UlbanTypography.titleSmall.copy(textDecoration = TextDecoration.Underline), + modifier = Modifier + .padding(10.dp) + .align(Alignment.CenterHorizontally) + .clickable { onExitClick() } + ) + } +} + +@Composable +fun SelectedPhotoCard( + defaultImage: Int?, + originalImage: String?, + bitmap: Bitmap?, + modifier: Modifier = Modifier +) { + Card( + elevation = CardDefaults.cardElevation( + defaultElevation = 4.dp, + pressedElevation = 8.dp + ), + colors = CardDefaults.cardColors( + containerColor = Cream + ), + modifier = modifier, + ) { + Column( + modifier = Modifier.fillMaxSize(), + ) { + if (originalImage == null && bitmap == null && defaultImage == null) { + Image( + painter = painterResource( + id = DesignSystemR.drawable.teacher_man + ), + contentDescription = "selected photo", + modifier = Modifier.fillMaxSize(), + ) + + } else if (originalImage != null) { + if (bitmap == null && defaultImage == null) { + AsyncImage( + model = originalImage, + contentDescription = "original image", + modifier = Modifier.fillMaxWidth(), + contentScale = ContentScale.Crop + ) + } else { + if (bitmap != null) { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = "selected photo", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } else { + Image( + painter = painterResource( + id = defaultImage!! + ), + contentDescription = "selected photo", + modifier = Modifier.fillMaxSize(), + ) + } + } + } + } + } +} + +@Composable +fun PhotoCard(modifier: Modifier = Modifier, img: Int, onClickPhoto: (Int) -> Unit) { + Card( + elevation = CardDefaults.cardElevation( + defaultElevation = 4.dp, + pressedElevation = 8.dp + ), + colors = CardDefaults.cardColors( + containerColor = Cream + ), + modifier = modifier.clickable { + onClickPhoto(img) + }, + ) { + Column( + modifier = Modifier.fillMaxSize(), + ) { + Image( + painter = painterResource(id = img), + contentDescription = "profile", + modifier = Modifier, + ) + } + } +} + +fun saveBitmapToFile(context: Context, bitmap: Bitmap?, fileName: String): File? { + val directory = context.getExternalFilesDir(null) ?: return null + if (bitmap == null) return null + val file = File(directory, fileName) + var fileOutputStream: FileOutputStream? = null + + try { + fileOutputStream = FileOutputStream(file) + bitmap.compress(Bitmap.CompressFormat.JPEG, 100, fileOutputStream) + fileOutputStream.flush() + } catch (e: Exception) { + e.printStackTrace() + return null + } finally { + try { + fileOutputStream?.close() + } catch (e: Exception) { + e.printStackTrace() + } + } + + return file +} + +@Composable +@Preview(showBackground = true) +fun TeacherProfileScreenPreview() { + UlbanTheme { + StudentProfileScreen() + } +} \ No newline at end of file diff --git a/android/feature/student/main/src/main/java/com/sixkids/student/main/profile/ProfileViewModel.kt b/android/feature/student/main/src/main/java/com/sixkids/student/main/profile/ProfileViewModel.kt new file mode 100644 index 00000000..c808379f --- /dev/null +++ b/android/feature/student/main/src/main/java/com/sixkids/student/main/profile/ProfileViewModel.kt @@ -0,0 +1,116 @@ +package com.sixkids.student.main.profile + +import android.graphics.Bitmap +import android.util.Log +import androidx.annotation.DrawableRes +import androidx.lifecycle.viewModelScope +import com.sixkids.domain.usecase.user.GetUserInfoUseCase +import com.sixkids.domain.usecase.user.SignOutUseCase +import com.sixkids.domain.usecase.user.UpdateUserProfilePhotoUseCase +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import java.io.File +import javax.inject.Inject + +private const val TAG = "D107" + +@HiltViewModel +class ProfileViewModel @Inject constructor( + private val getUserInfoUseCase: GetUserInfoUseCase, + private val updateUserProfilePhotoUseCase: UpdateUserProfilePhotoUseCase, + private val signOutUseCase: SignOutUseCase +): BaseViewModel(ProfileState()){ + fun initData() { + viewModelScope.launch { + getUserInfoUseCase() + .onSuccess { + intent { + copy( + name = it.name, + originalProfilePhoto = it.photo + ) + } + }.onFailure { + postSideEffect( + ProfileEffect.OnShowSnackBar( + SnackbarToken( + it.message ?: "알 수 없는 오류가 발생했습니다." + ) + ) + ) + } + + } + } + + fun onProfilePhotoSelected(bitmap: Bitmap) { + intent { + copy( + changedProfileUserPhoto = bitmap, + changedProfileDefaultPhoto = null, + gender = null + ) + } + } + + fun onProfileDefaultPhotoSelected(@DrawableRes photo: Int, gender: Gender) { + intent { + copy( + changedProfileDefaultPhoto = photo, + changedProfileUserPhoto = null, + gender = gender + ) + } + } + + fun onChangeDoneClick(newProfilePhoto: File?) { + viewModelScope.launch { + intent { copy(isLoading = true) } + var defaultImage = 0 + if (newProfilePhoto == null && uiState.value.changedProfileDefaultPhoto == null) { + // 변경사항 없음 뒤로가기 + postSideEffect(ProfileEffect.NavigateToOrganizationList) + } else { + defaultImage = when (newProfilePhoto) { + null -> { + when (uiState.value.gender) { + null -> 0 + Gender.MAN -> 3 + Gender.WOMAN -> 4 + } + } + else -> 0 + } + } + + updateUserProfilePhotoUseCase(newProfilePhoto, defaultImage) + .onSuccess { + postSideEffect(ProfileEffect.NavigateToOrganizationList) + }.onFailure { + Log.d(TAG, "onChangeDoneClick: ${it.message}") + postSideEffect( + ProfileEffect.OnShowSnackBar( + SnackbarToken( + it.message ?: "알 수 없는 오류가 발생했습니다." + ) + ) + ) + } + intent { copy(isLoading = false) } + } + } + + fun onSignOutClick() { + viewModelScope.launch { + if(signOutUseCase()){ + postSideEffect(ProfileEffect.NavigateToSignIn) + }else{ + ProfileEffect.OnShowSnackBar( + SnackbarToken("로그아웃에 실패했습니다. 다시 시도해주세요.") + ) + } + } + } +} \ No newline at end of file diff --git a/android/feature/student/main/src/main/res/values/strings.xml b/android/feature/student/main/src/main/res/values/strings.xml new file mode 100644 index 00000000..d16af1f5 --- /dev/null +++ b/android/feature/student/main/src/main/res/values/strings.xml @@ -0,0 +1,20 @@ + + + + %s 학생 환영합니다 + 학급이 없습니다 + 입장 + + + 안녕하세요 %s 학생! + 완료 + 로그아웃 + 회원 탈퇴 + + + 선생님께 받은 초대코드를 입력하세요 + 선생님께 받은 학급 ID를 입력하세요 + 학급 가입 + 초대 코드 + 초대 코드 + \ No newline at end of file diff --git a/android/feature/student/main/src/test/java/com/sixkids/student/main/ExampleUnitTest.kt b/android/feature/student/main/src/test/java/com/sixkids/student/main/ExampleUnitTest.kt new file mode 100644 index 00000000..cc209d45 --- /dev/null +++ b/android/feature/student/main/src/test/java/com/sixkids/student/main/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.sixkids.student.main + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/android/feature/student/relay/build.gradle.kts b/android/feature/student/relay/build.gradle.kts index 02cba040..99f78b85 100644 --- a/android/feature/student/relay/build.gradle.kts +++ b/android/feature/student/relay/build.gradle.kts @@ -1,5 +1,6 @@ plugins { alias(libs.plugins.sixkids.android.feature.compose) + kotlin("plugin.serialization") version "1.9.24" } android { @@ -7,4 +8,9 @@ android { } dependencies { + implementation(libs.bundles.paging) + + implementation(libs.kotlinx.serialization.json) + + implementation(projects.core.nfc) } diff --git a/android/feature/student/relay/src/main/java/com/sixkids/student/relay/create/RelayCreateContract.kt b/android/feature/student/relay/src/main/java/com/sixkids/student/relay/create/RelayCreateContract.kt new file mode 100644 index 00000000..2359b180 --- /dev/null +++ b/android/feature/student/relay/src/main/java/com/sixkids/student/relay/create/RelayCreateContract.kt @@ -0,0 +1,16 @@ +package com.sixkids.student.relay.create + +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState + +data class RelayCreateState( + val isLoading: Boolean = false, + val question: String = "", + val orgId: Int = -1 +) : UiState + +sealed interface RelayCreateEffect: SideEffect { + data object NavigateToRelayResult : RelayCreateEffect + data class OnShowSnackBar(val tkn : SnackbarToken) : RelayCreateEffect +} \ No newline at end of file diff --git a/android/feature/student/relay/src/main/java/com/sixkids/student/relay/create/RelayCreateScreen.kt b/android/feature/student/relay/src/main/java/com/sixkids/student/relay/create/RelayCreateScreen.kt new file mode 100644 index 00000000..c23846f9 --- /dev/null +++ b/android/feature/student/relay/src/main/java/com/sixkids/student/relay/create/RelayCreateScreen.kt @@ -0,0 +1,101 @@ +package com.sixkids.student.relay.create + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.size +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.hilt.navigation.compose.hiltViewModel +import com.sixkids.designsystem.component.button.UlbanFilledButton +import com.sixkids.designsystem.component.screen.UlbanTopSection +import com.sixkids.designsystem.theme.UlbanTheme +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.student.relay.R +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.extension.collectWithLifecycle +import com.sixkids.designsystem.R as DesignSystemR + +@Composable +fun RelayCreateRoute( + viewModel: RelayCreateViewModel = hiltViewModel(), + navigateToRelayResult: () -> Unit, + onBackClick: () -> Unit, + onShowSnackBar: (SnackbarToken) -> Unit +) { + viewModel.sideEffect.collectWithLifecycle { sideEffect -> + when (sideEffect) { + is RelayCreateEffect.NavigateToRelayResult -> navigateToRelayResult() + is RelayCreateEffect.OnShowSnackBar -> onShowSnackBar(sideEffect.tkn) + } + } + + LaunchedEffect(key1 = Unit) { + viewModel.init() + } + + RelayCreateScreen( + onNewRelayClick = viewModel::newRelayClick, + onBackClick = onBackClick, + ) + +} + +@Composable +fun RelayCreateScreen( + paddingValues: PaddingValues = PaddingValues(20.dp), + onNewRelayClick: () -> Unit = {}, + onBackClick: () -> Unit = {} +) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + Column( + modifier = Modifier + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + UlbanTopSection(stringResource(id = R.string.relay_create_topsection), onBackClick) + + Spacer(modifier = Modifier.weight(1f)) + + Image( + painter = painterResource(id = DesignSystemR.drawable.relay), + contentDescription = "relay", + modifier = Modifier.padding(bottom = 20.dp).size(250.dp) + ) + + Text(text = "새로운 이어 달리기를 만듭니다!", style = UlbanTypography.titleMedium) + + Spacer(modifier = Modifier.weight(2f)) + + UlbanFilledButton( + text = stringResource(R.string.relay_create_create), + onClick = { onNewRelayClick() }, + modifier = Modifier.fillMaxWidth() + ) + } + } +} + +@Composable +@Preview(showBackground = true) +fun RelayCreateScreenPreview() { + UlbanTheme { + RelayCreateScreen() + } +} \ No newline at end of file diff --git a/android/feature/student/relay/src/main/java/com/sixkids/student/relay/create/RelayCreateViewModel.kt b/android/feature/student/relay/src/main/java/com/sixkids/student/relay/create/RelayCreateViewModel.kt new file mode 100644 index 00000000..4c0de27e --- /dev/null +++ b/android/feature/student/relay/src/main/java/com/sixkids/student/relay/create/RelayCreateViewModel.kt @@ -0,0 +1,50 @@ +package com.sixkids.student.relay.create + +import android.util.Log +import androidx.lifecycle.viewModelScope +import com.sixkids.domain.usecase.organization.GetSelectedOrganizationIdUseCase +import com.sixkids.domain.usecase.relay.CreateRelayUseCase +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +private const val TAG = "D107" +@HiltViewModel +class RelayCreateViewModel @Inject constructor( + private val getSelectedOrganizationIdUseCase: GetSelectedOrganizationIdUseCase, + private val createRelayUseCase: CreateRelayUseCase +) : BaseViewModel(RelayCreateState()) { + + fun init() { + viewModelScope.launch { + getSelectedOrganizationIdUseCase() + .onSuccess { + intent { copy(orgId = it) } + }.onFailure { + postSideEffect(RelayCreateEffect.OnShowSnackBar(SnackbarToken("학급 정보를 불러오는데 실패했습니다"))) + } + } + } + + fun newRelayClick() { + viewModelScope.launch { + intent { copy(isLoading = true) } + if (uiState.value.orgId == -1){ + init() + }else{ + Log.d(TAG, "newRelayClick: ${uiState.value.question} ${uiState.value.orgId}") + createRelayUseCase(uiState.value.orgId, "첫번째 주자입니다!") + .onSuccess { + if (it>0){ + postSideEffect(RelayCreateEffect.NavigateToRelayResult) + } + + }.onFailure { + postSideEffect(RelayCreateEffect.OnShowSnackBar(SnackbarToken("이어 달리기 생성에 실패했습니다"))) + } + } + } + } +} \ No newline at end of file diff --git a/android/feature/student/relay/src/main/java/com/sixkids/student/relay/detail/RelayDetailContract.kt b/android/feature/student/relay/src/main/java/com/sixkids/student/relay/detail/RelayDetailContract.kt new file mode 100644 index 00000000..e0871b22 --- /dev/null +++ b/android/feature/student/relay/src/main/java/com/sixkids/student/relay/detail/RelayDetailContract.kt @@ -0,0 +1,15 @@ +package com.sixkids.student.relay.detail + +import com.sixkids.model.RelayDetail +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState + +data class RelayDetailState( + val isLoading: Boolean = false, + val relayDetail: RelayDetail = RelayDetail(), +) : UiState + +sealed interface RelayDetailSideEffect : SideEffect{ + data class HandleException(val throwable: Throwable, val retry: () -> Unit) : + RelayDetailSideEffect +} \ No newline at end of file diff --git a/android/feature/student/relay/src/main/java/com/sixkids/student/relay/detail/RelayDetailScreen.kt b/android/feature/student/relay/src/main/java/com/sixkids/student/relay/detail/RelayDetailScreen.kt new file mode 100644 index 00000000..5f61df2a --- /dev/null +++ b/android/feature/student/relay/src/main/java/com/sixkids/student/relay/detail/RelayDetailScreen.kt @@ -0,0 +1,149 @@ +package com.sixkids.student.relay.detail + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +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.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.sixkids.designsystem.component.appbar.UlbanDetailAppBar +import com.sixkids.designsystem.component.item.UlbanRunnerItem +import com.sixkids.designsystem.theme.Orange +import com.sixkids.designsystem.theme.UlbanTheme +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.student.relay.R +import com.sixkids.ui.extension.collectWithLifecycle +import com.sixkids.ui.util.formatToMonthDayTime +import com.sixkids.designsystem.R as DesignSystemR + + +@Composable +fun RelayDetailRoute( + viewModel: RelayDetailViewModel = hiltViewModel(), + handleException: (Throwable, () -> Unit) -> Unit +){ + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + viewModel.sideEffect.collectWithLifecycle { + when (it) { + is RelayDetailSideEffect.HandleException -> handleException(it.throwable, it.retry) + } + } + LaunchedEffect(key1 = Unit) { + viewModel.getRelayDetail() + } + + RelayDetailScreen( + uiState = uiState + ) + +} + +@Composable +fun RelayDetailScreen( + uiState: RelayDetailState = RelayDetailState() +) { + val listState = rememberLazyListState() + val isScrolled by remember { + derivedStateOf { + listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 100 + } + } + + Box(modifier = Modifier.fillMaxSize()) + { + Column( + modifier = Modifier + .fillMaxSize() + ) { + UlbanDetailAppBar( + leftIcon = DesignSystemR.drawable.relay, + title = stringResource(id = R.string.relay_challenge), + content = stringResource(id = R.string.relay_challenge), + topDescription = "${uiState.relayDetail.startTime.formatToMonthDayTime()} ~ ${uiState.relayDetail.endTime.formatToMonthDayTime()}", + bottomDescription = stringResource( + id = R.string.relay_detail_last_member, + uiState.relayDetail.lastMemberName + ), + color = Orange, + expanded = !isScrolled, + ) + Column( + modifier = Modifier + .fillMaxSize() + .padding(top = 8.dp, start = 16.dp, end = 16.dp, bottom = 16.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + modifier = Modifier + .padding(start = 4.dp) + .size(32.dp), + painter = painterResource(id = DesignSystemR.drawable.member), + tint = Color.Unspecified, + contentDescription = null + ) + Text( + text = stringResource( + id = R.string.relay_detail_total_count, + uiState.relayDetail.lastTurn + ), + style = UlbanTypography.bodyMedium.copy(fontWeight = FontWeight.Bold) + ) + } + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + if (uiState.relayDetail.runnerList.isEmpty()) { + Text( + text = stringResource(id = R.string.relay_no_history), + style = UlbanTypography.bodyMedium.copy(fontWeight = FontWeight.Bold) + ) + } else { + LazyColumn( + state = listState, + ) { + items(uiState.relayDetail.runnerList) { runner -> + UlbanRunnerItem( + memberPhoto = runner.memberPhoto, + memberName = runner.memberName, + time = runner.time, + question = runner.question, + isLastTurn = runner.endStatus + ) + } + } + } + } + } + } +} + +@Composable +@Preview(showBackground = true) +fun RelayDetailScreenPreview() { + UlbanTheme { + RelayDetailScreen() + } +} diff --git a/android/feature/student/relay/src/main/java/com/sixkids/student/relay/detail/RelayDetailViewModel.kt b/android/feature/student/relay/src/main/java/com/sixkids/student/relay/detail/RelayDetailViewModel.kt new file mode 100644 index 00000000..8c9b78d5 --- /dev/null +++ b/android/feature/student/relay/src/main/java/com/sixkids/student/relay/detail/RelayDetailViewModel.kt @@ -0,0 +1,34 @@ +package com.sixkids.student.relay.detail + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.sixkids.domain.usecase.relay.GetRelayDetailUseCase +import com.sixkids.student.relay.navigation.RelayRoute.RELAY_ID_NAME +import com.sixkids.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + + +@HiltViewModel +class RelayDetailViewModel @Inject constructor( + private val getRelayDetailUseCase: GetRelayDetailUseCase, + savedStateHandle: SavedStateHandle +) : BaseViewModel(RelayDetailState()) +{ + private val relayId = savedStateHandle.get(RELAY_ID_NAME) + + fun getRelayDetail() { + viewModelScope.launch { + intent { copy(isLoading = true) } + getRelayDetailUseCase(relayId!!) + .onSuccess { relayDetail -> + intent { copy(relayDetail = relayDetail) } + } + .onFailure { exception -> + postSideEffect(RelayDetailSideEffect.HandleException(exception, ::getRelayDetail)) + } + intent { copy(isLoading = false) } + } + } +} \ No newline at end of file diff --git a/android/feature/student/relay/src/main/java/com/sixkids/student/relay/history/RelayHistoryContract.kt b/android/feature/student/relay/src/main/java/com/sixkids/student/relay/history/RelayHistoryContract.kt new file mode 100644 index 00000000..09a5997d --- /dev/null +++ b/android/feature/student/relay/src/main/java/com/sixkids/student/relay/history/RelayHistoryContract.kt @@ -0,0 +1,20 @@ +package com.sixkids.student.relay.history + +import com.sixkids.model.RunningRelay +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState + +data class RelayHistoryState( + val isLoading: Boolean = false, + val runningRelay: RunningRelay? = null, + val totalRelayCount: Int = 0, +) : UiState + +sealed interface RelayHistoryEffect : SideEffect { + data class NavigateToRelayDetail(val relayId: Long) : RelayHistoryEffect + data object NavigateToCreateRelay : RelayHistoryEffect + data class NavigateToAnswerRelay(val relayId: Long) : RelayHistoryEffect + data class NavigateToTaggingReceiverRelay(val relayId: Long) : RelayHistoryEffect + data object NavigateToJoinRelay : RelayHistoryEffect + data class HandleException(val throwable: Throwable, val retry: () -> Unit) : RelayHistoryEffect +} \ No newline at end of file diff --git a/android/feature/student/relay/src/main/java/com/sixkids/student/relay/history/RelayHistoryScreen.kt b/android/feature/student/relay/src/main/java/com/sixkids/student/relay/history/RelayHistoryScreen.kt new file mode 100644 index 00000000..7e956012 --- /dev/null +++ b/android/feature/student/relay/src/main/java/com/sixkids/student/relay/history/RelayHistoryScreen.kt @@ -0,0 +1,213 @@ +package com.sixkids.student.relay.history + +import android.util.Log +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import com.sixkids.designsystem.component.appbar.UlbanDefaultAppBar +import com.sixkids.designsystem.component.appbar.UlbanDetailAppBar +import com.sixkids.designsystem.component.item.UlbanRelayItem +import com.sixkids.designsystem.component.screen.LoadingScreen +import com.sixkids.designsystem.theme.Orange +import com.sixkids.designsystem.theme.UlbanTheme +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.model.Relay +import com.sixkids.student.relay.R +import com.sixkids.ui.util.formatToMonthDayTime +import com.sixkids.designsystem.R as DesignSystemR + +private const val TAG = "D107" +@Composable +fun RelayRoute( + viewModel: RelayHistoryViewModel = hiltViewModel(), + padding: PaddingValues, + navigateToDetail: (Long) -> Unit, + navigateToCreate: () -> Unit, + navigateToAnswer: (Long) -> Unit, + navigateToTaggingReceiver: (Long) -> Unit, + navigateToJoin: () -> Unit, + handleException: (Throwable, () -> Unit) -> Unit +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(key1 = Unit) { + Log.d(TAG, "RelayRoute: ") + viewModel.initData() + } + + LaunchedEffect(key1 = viewModel.sideEffect) { + viewModel.sideEffect.collect { sideEffect -> + when (sideEffect) { + is RelayHistoryEffect.NavigateToRelayDetail -> navigateToDetail(sideEffect.relayId) + is RelayHistoryEffect.NavigateToCreateRelay -> navigateToCreate() + is RelayHistoryEffect.NavigateToJoinRelay -> navigateToJoin() + is RelayHistoryEffect.NavigateToAnswerRelay -> navigateToAnswer(sideEffect.relayId) + is RelayHistoryEffect.NavigateToTaggingReceiverRelay -> navigateToTaggingReceiver(sideEffect.relayId) + is RelayHistoryEffect.HandleException -> handleException( + sideEffect.throwable, + sideEffect.retry + ) + } + } + } + + RelayHistoryScreen( + uiState = uiState, + padding = padding, + relayItems = viewModel.relayHistory?.collectAsLazyPagingItems(), + navigateToDetail = { relayId -> + viewModel.navigateToRelayDetail(relayId) + }, + navigateToCreate = navigateToCreate, + navigateToAnswer = { relayId -> + viewModel.navigateToAnswerRelay(relayId) + }, + navigateToTaggingReceiver = { relayId -> + viewModel.navigateToTaggingReceiverRelay(relayId) + }, + updateTotalCount = { + viewModel.updateTotalCount(it) + } + ) +} + +@Composable +fun RelayHistoryScreen( + uiState: RelayHistoryState = RelayHistoryState(), + padding: PaddingValues = PaddingValues(0.dp), + relayItems: LazyPagingItems? = null, + navigateToDetail: (Long) -> Unit = {}, + navigateToCreate: () -> Unit = {}, + navigateToAnswer: (Long) -> Unit = {}, + navigateToTaggingReceiver: (Long) -> Unit = {}, + updateTotalCount: (Int) -> Unit = {} +) { + val listState = rememberLazyListState() + val isScrolled by remember { + derivedStateOf { + listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 100 + } + } + + Box(modifier = Modifier + .fillMaxSize() + .padding(padding)) { + Column( + modifier = Modifier.fillMaxSize() + ) { + val currentRelay = uiState.runningRelay + if (currentRelay == null) { + UlbanDefaultAppBar( + leftIcon = DesignSystemR.drawable.relay, + title = stringResource(R.string.relay_challenge), + content = stringResource(R.string.relay_create), + color = Orange, + onclick = navigateToCreate, + expanded = !isScrolled + ) + } else { + UlbanDetailAppBar( + leftIcon = DesignSystemR.drawable.relay, + title = stringResource(R.string.relay_challenge), + content = if (currentRelay.myTurnStatus) stringResource(R.string.relay_running_myturn) else stringResource( + R.string.relay_running_not_myturn + ), + topDescription = "${currentRelay.startTime.formatToMonthDayTime()} ~", + bottomDescription = if (currentRelay.myTurnStatus) stringResource(R.string.relay_answer_myturn) else stringResource( + R.string.relay_answer_not_myturn + ), + color = Orange, + onclick = { if (currentRelay.myTurnStatus) navigateToAnswer(currentRelay.id) else navigateToTaggingReceiver(currentRelay.id) }, + expanded = !isScrolled, + ) + } + + Spacer(modifier = Modifier.padding(12.dp)) + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + ) { + Text( + modifier = Modifier.padding(horizontal = 4.dp), + text = stringResource( + id = R.string.relay_relay_count, + uiState.totalRelayCount + ), + style = UlbanTypography.bodyMedium.copy(fontWeight = FontWeight.Bold) + ) + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + + if (relayItems == null || relayItems.itemCount == 0) { + Spacer(modifier = Modifier.weight(1f)) + Text( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + text = stringResource(R.string.relay_no_history), + style = UlbanTypography.titleLarge, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.weight(1f)) + } else { + LazyColumn( + state = listState, + contentPadding = PaddingValues(vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + items(relayItems.itemCount) { index -> + relayItems[index]?.let { relay -> + if (index == 0) + updateTotalCount(relay.totalCount) + UlbanRelayItem( + startDate = relay.startTime, + endDate = relay.endTime, + userCount = relay.lastTurn, + lastMemberName = relay.lastMemberName, + onClick = {navigateToDetail(relay.id)} + ) + } + } + } + } + } + } + if (uiState.isLoading) { + LoadingScreen() + } + } +} + +@Composable +@Preview(showBackground = true) +fun RelayHistoryScreenPreview() { + UlbanTheme { + RelayHistoryScreen() + } +} \ No newline at end of file diff --git a/android/feature/student/relay/src/main/java/com/sixkids/student/relay/history/RelayHistoryViewModel.kt b/android/feature/student/relay/src/main/java/com/sixkids/student/relay/history/RelayHistoryViewModel.kt new file mode 100644 index 00000000..35ec70e2 --- /dev/null +++ b/android/feature/student/relay/src/main/java/com/sixkids/student/relay/history/RelayHistoryViewModel.kt @@ -0,0 +1,103 @@ +package com.sixkids.student.relay.history + +import android.util.Log +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn +import androidx.paging.map +import com.sixkids.domain.usecase.organization.GetSelectedOrganizationIdUseCase +import com.sixkids.domain.usecase.relay.GetRelayHistoryUseCase +import com.sixkids.domain.usecase.relay.GetRunningRelayUseCase +import com.sixkids.domain.usecase.user.LoadUserInfoUseCase +import com.sixkids.model.NotFoundException +import com.sixkids.model.Relay +import com.sixkids.model.RunningRelay +import com.sixkids.model.UserInfo +import com.sixkids.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import java.time.LocalDateTime +import javax.inject.Inject + +private const val TAG = "D107" +@HiltViewModel +class RelayHistoryViewModel @Inject constructor( + private val getSelectedOrganizationIdUseCase: GetSelectedOrganizationIdUseCase, + private val loadUserInfoUseCase: LoadUserInfoUseCase, + private val getRunningRelayUseCase: GetRunningRelayUseCase, + private val getRelayHistoryUseCase: GetRelayHistoryUseCase +) : BaseViewModel(RelayHistoryState()) +{ + private var orgId = 0L + private lateinit var userInfo: UserInfo + var relayHistory: Flow>? = null + private var isFirstVisited: Boolean = true + + fun initData() = viewModelScope.launch { + if (isFirstVisited.not()) return@launch + isFirstVisited = false + + intent { copy(isLoading = true) } + + getSelectedOrganizationIdUseCase().onSuccess { + orgId = it.toLong() + }.onFailure { + postSideEffect(RelayHistoryEffect.HandleException(it, ::initData)) + } + + loadUserInfoUseCase().onSuccess { + userInfo = it + }.onFailure { + postSideEffect(RelayHistoryEffect.HandleException(it, ::initData)) + } + + getRunningRelay() + getRelayHistory() + + intent { copy(isLoading = false) } + } + + private fun getRunningRelay() { + viewModelScope.launch { + getRunningRelayUseCase(organizationId = orgId) + .onSuccess { + intent { copy(runningRelay = it) } + }.onFailure { + if (it is NotFoundException){ + intent { copy(runningRelay = null) } + }else{ + postSideEffect( + RelayHistoryEffect.HandleException( + it, + ::getRunningRelay + ) + ) + } + } + } + } + + private fun getRelayHistory() { + viewModelScope.launch { + relayHistory = getRelayHistoryUseCase(organizationId = orgId.toInt(), memberId = userInfo.id) + .cachedIn(viewModelScope) + } + } + + fun updateTotalCount(totalCount: Int) = intent { copy(totalRelayCount = totalCount) } + + fun navigateToRelayDetail(relayId: Long) = postSideEffect( + RelayHistoryEffect.NavigateToRelayDetail(relayId) + ) + + fun navigateToAnswerRelay(relayId: Long) = postSideEffect( + RelayHistoryEffect.NavigateToAnswerRelay(relayId) + ) + + fun navigateToTaggingReceiverRelay(relayId: Long) = postSideEffect( + RelayHistoryEffect.NavigateToTaggingReceiverRelay(relayId) + ) + +} \ No newline at end of file diff --git a/android/feature/student/relay/src/main/java/com/sixkids/student/relay/navigation/RelayNavigation.kt b/android/feature/student/relay/src/main/java/com/sixkids/student/relay/navigation/RelayNavigation.kt new file mode 100644 index 00000000..0da7b65f --- /dev/null +++ b/android/feature/student/relay/src/main/java/com/sixkids/student/relay/navigation/RelayNavigation.kt @@ -0,0 +1,163 @@ +package com.sixkids.student.relay.navigation + +import androidx.compose.foundation.layout.PaddingValues +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.NavType +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import com.sixkids.student.relay.create.RelayCreateRoute +import com.sixkids.student.relay.detail.RelayDetailRoute +import com.sixkids.student.relay.history.RelayRoute +import com.sixkids.student.relay.navigation.RelayRoute.RELAY_ID_NAME +import com.sixkids.student.relay.pass.answer.RelayAnswerRoute +import com.sixkids.student.relay.pass.tagging.receiver.RelayTaggingReceiverRoute +import com.sixkids.student.relay.pass.tagging.sender.RelayTaggingSenderRoute +import com.sixkids.student.relay.result.RelayCreateResultRoute +import com.sixkids.ui.SnackbarToken + +fun NavController.navigateStudentRelayHistory(navOptions: NavOptions) { + navigate(RelayRoute.defaultRoute, navOptions) +} + +fun NavController.navigateStudentRelayDetail(relayId: Long) { + navigate(RelayRoute.detailRoute(relayId)) +} + +fun NavController.navigateStudentRelayCreate() { + navigate(RelayRoute.createRoute) +} + +fun NavController.navigateStudentRelayCreateResult() { + navigate(RelayRoute.createResultRoute) +} + +fun NavController.navigateStudentRelayJoin() { + navigate(RelayRoute.joinRoute) +} + +fun NavController.navigateStudentRelayAnswer(relayId: Long) { + navigate(RelayRoute.answerRoute(relayId)) +} + +fun NavController.navigateStudentRelayTaggingSender(relayId: Long, question: String) { + navigate(RelayRoute.taggingSenderRoute(relayId, question)) +} + +fun NavController.navigateStudentRelayTaggingReceiver(relayId: Long) { + navigate(RelayRoute.taggingReceiverRoute(relayId)) +} + +fun NavGraphBuilder.studentRelayNavGraph( + padding: PaddingValues, + navigateRelayHistory: () -> Unit, + navigateRelayDetail: (Long) -> Unit, + navigateCreateRelay: () -> Unit, + navigateJoinRelay: () -> Unit, + navigateCreateRelayResult: () -> Unit, + navigateAnswerRelay: (Long) -> Unit, + navigateTaggingSender: (Long, String) -> Unit, + navigateTaggingReceiver: (Long) -> Unit, + onBackClick: () -> Unit, + onShowSnackBar: (SnackbarToken) -> Unit, + handleException: (Throwable, () -> Unit) -> Unit +) { + composable(route = RelayRoute.defaultRoute) + { + RelayRoute( + padding = padding, + navigateToDetail = { relayId -> + navigateRelayDetail(relayId) + }, + navigateToCreate = navigateCreateRelay, + navigateToAnswer = navigateAnswerRelay, + navigateToTaggingReceiver = navigateTaggingReceiver, + navigateToJoin = navigateJoinRelay, + handleException = handleException + ) + } + + composable(route = RelayRoute.detailRoute, + arguments = listOf( + navArgument(RELAY_ID_NAME) { type = NavType.LongType }, + + )) + { + + RelayDetailRoute( + handleException = handleException + ) + } + + composable(route = RelayRoute.createRoute) + { + RelayCreateRoute( + navigateToRelayResult = navigateCreateRelayResult, + onBackClick = onBackClick, + onShowSnackBar = onShowSnackBar + ) + } + + composable(route = RelayRoute.createResultRoute) + { + RelayCreateResultRoute( + navigateToRelayHistory = navigateRelayHistory, + handleException = handleException + ) + } + + composable(route = RelayRoute.answerRoute, + arguments = listOf( + navArgument(RELAY_ID_NAME) { type = NavType.LongType }, + + )) + { + RelayAnswerRoute( + navigateToTaggingSenderRelay = navigateTaggingSender, + onBackClick = onBackClick, + onShowSnackBar = onShowSnackBar + ) + } + + composable(route = RelayRoute.taggingSenderRoute, + arguments = listOf( + navArgument(RELAY_ID_NAME) { type = NavType.LongType }, + navArgument(RelayRoute.RELAY_QUESTION_NAME) { type = NavType.StringType } + )) + { + RelayTaggingSenderRoute( + navigateToRelayHistory = navigateRelayHistory, + onShowSnackBar = onShowSnackBar + ) + } + + composable(route = RelayRoute.taggingReceiverRoute, + arguments = listOf( + navArgument(RELAY_ID_NAME) { type = NavType.LongType } + )) + { + RelayTaggingReceiverRoute( + navigateToRelayHistory = navigateRelayHistory, + ) + } +} + +object RelayRoute { + const val RELAY_ID_NAME = "relayId" + const val RELAY_QUESTION_NAME = "question" + + const val defaultRoute = "student/relay-history" + const val createRoute = "relay-create" + const val detailRoute = "relay-detail?relayId={$RELAY_ID_NAME}" + const val answerRoute = "relay-answer?relayId={$RELAY_ID_NAME}" + const val taggingSenderRoute = "relay-tagging-sender?relayId={$RELAY_ID_NAME}&question={$RELAY_QUESTION_NAME}" + const val taggingReceiverRoute = "relay-tagging-receiver?relayId={$RELAY_ID_NAME}" + const val createResultRoute = "relay-create-result" + const val joinRoute = "relay-join" + + fun detailRoute(relayId: Long) = "relay-detail?relayId=$relayId" + fun answerRoute(relayId: Long) = "relay-answer?relayId=$relayId" + fun taggingSenderRoute(relayId: Long, question: String) = "relay-tagging-sender?relayId=$relayId&question=$question" + fun taggingReceiverRoute(relayId: Long) = "relay-tagging-receiver?relayId=$relayId" +} \ No newline at end of file diff --git a/android/feature/student/relay/src/main/java/com/sixkids/student/relay/pass/answer/RelayAnswerContract.kt b/android/feature/student/relay/src/main/java/com/sixkids/student/relay/pass/answer/RelayAnswerContract.kt new file mode 100644 index 00000000..32ea42b7 --- /dev/null +++ b/android/feature/student/relay/src/main/java/com/sixkids/student/relay/pass/answer/RelayAnswerContract.kt @@ -0,0 +1,16 @@ +package com.sixkids.student.relay.pass.answer + +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState + +data class RelayAnswerState( + val isLoading: Boolean = false, + val preQuestion: String = "", + val nextQuestion: String = "" +): UiState + +sealed interface RelayAnswerEffect: SideEffect{ + data class NavigateToTaggingSenderRelay(val relayId: Long, val question: String): RelayAnswerEffect + data class OnShowSnackBar(val tkn: SnackbarToken): RelayAnswerEffect +} \ No newline at end of file diff --git a/android/feature/student/relay/src/main/java/com/sixkids/student/relay/pass/answer/RelayAnswerScreen.kt b/android/feature/student/relay/src/main/java/com/sixkids/student/relay/pass/answer/RelayAnswerScreen.kt new file mode 100644 index 00000000..e71c5705 --- /dev/null +++ b/android/feature/student/relay/src/main/java/com/sixkids/student/relay/pass/answer/RelayAnswerScreen.kt @@ -0,0 +1,128 @@ +package com.sixkids.student.relay.pass.answer + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.material3.Divider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.sixkids.designsystem.component.button.UlbanFilledButton +import com.sixkids.designsystem.component.screen.UlbanTopSection +import com.sixkids.designsystem.component.textfield.UlbanUnderLineTextField +import com.sixkids.designsystem.theme.Blue +import com.sixkids.designsystem.theme.UlbanTheme +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.student.relay.R +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.extension.collectWithLifecycle + +@Composable +fun RelayAnswerRoute( + viewModel: RelayAnswerViewModel = hiltViewModel(), + navigateToTaggingSenderRelay: (Long, String) -> Unit, + onBackClick: () -> Unit, + onShowSnackBar: (SnackbarToken) -> Unit +) { + val uiState = viewModel.uiState.collectAsStateWithLifecycle().value + + viewModel.sideEffect.collectWithLifecycle { sideEffect -> + when (sideEffect) { + is RelayAnswerEffect.NavigateToTaggingSenderRelay -> navigateToTaggingSenderRelay(sideEffect.relayId, sideEffect.question) + is RelayAnswerEffect.OnShowSnackBar -> onShowSnackBar(sideEffect.tkn) + } + } + + LaunchedEffect(key1 = Unit) { + viewModel.init() + } + + RelayAnswerScreen( + uiState = uiState, + onNextClick = viewModel::nextClick, + onBackClick = onBackClick, + onUpdateNextQuestion = viewModel::updateNextQuestion + ) +} + +@Composable +fun RelayAnswerScreen( + paddingValues: PaddingValues = PaddingValues(20.dp), + uiState: RelayAnswerState = RelayAnswerState(), + onNextClick: () -> Unit = {}, + onBackClick: () -> Unit = {}, + onUpdateNextQuestion: (String) -> Unit = {} +) { + Box(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + horizontalAlignment = Alignment.Start, + ) { + UlbanTopSection(stringResource(R.string.relay_answer_title), onBackClick) + + Spacer(modifier = Modifier.height(36.dp)) + + Text( + text = stringResource(R.string.relay_create_question), + style = UlbanTypography.bodyLarge, + modifier = Modifier.padding(top = 10.dp, bottom = 10.dp) + ) + UlbanUnderLineTextField( + text = uiState.nextQuestion, + hint = stringResource(R.string.relay_create_question_hint), + onTextChange = onUpdateNextQuestion, + onIconClick = { + onUpdateNextQuestion("") + } + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = stringResource(R.string.relay_answer_pre_question), + style = UlbanTypography.bodyLarge, + modifier = Modifier.padding(top = 10.dp, bottom = 10.dp) + ) + + Column { + Text(text = uiState.preQuestion, style = UlbanTypography.bodyMedium) + + Divider( + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + color = Blue, + thickness = 2.dp + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + UlbanFilledButton( + text = stringResource(R.string.button_next), + onClick = { onNextClick() }, + modifier = Modifier.fillMaxWidth() + ) + } + } +} + +@Composable +@Preview(showBackground = true) +fun RelayAnswerScreenPreview() { + UlbanTheme { + RelayAnswerScreen() + } +} \ No newline at end of file diff --git a/android/feature/student/relay/src/main/java/com/sixkids/student/relay/pass/answer/RelayAnswerViewModel.kt b/android/feature/student/relay/src/main/java/com/sixkids/student/relay/pass/answer/RelayAnswerViewModel.kt new file mode 100644 index 00000000..95f89078 --- /dev/null +++ b/android/feature/student/relay/src/main/java/com/sixkids/student/relay/pass/answer/RelayAnswerViewModel.kt @@ -0,0 +1,44 @@ +package com.sixkids.student.relay.pass.answer + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.sixkids.domain.usecase.relay.GetRelayQuestionUseCase +import com.sixkids.model.NotFoundException +import com.sixkids.student.relay.navigation.RelayRoute +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class RelayAnswerViewModel @Inject constructor( + private val getRelayQuestionUseCase: GetRelayQuestionUseCase, + savedStateHandle: SavedStateHandle +): BaseViewModel(RelayAnswerState()) { + private val relayId = savedStateHandle.get(RelayRoute.RELAY_ID_NAME) + + fun init() { + viewModelScope.launch { + intent { copy(isLoading = true) } + getRelayQuestionUseCase(relayId!!) + .onSuccess { question -> + intent { copy(preQuestion = question) } + } + .onFailure { exception -> + when(exception){ + is NotFoundException -> intent { copy(preQuestion = "첫번째 주자입니다!")} + else -> postSideEffect(RelayAnswerEffect.OnShowSnackBar(SnackbarToken(exception.message ?: "질문을 받아오는데 실패했습니다"))) + } + } + } + } + + fun updateNextQuestion(question: String) { + intent { copy(nextQuestion = question) } + } + + fun nextClick() { + postSideEffect(RelayAnswerEffect.NavigateToTaggingSenderRelay(relayId!!, uiState.value.nextQuestion)) + } +} \ No newline at end of file diff --git a/android/feature/student/relay/src/main/java/com/sixkids/student/relay/pass/tagging/RelayNfc.kt b/android/feature/student/relay/src/main/java/com/sixkids/student/relay/pass/tagging/RelayNfc.kt new file mode 100644 index 00000000..10279711 --- /dev/null +++ b/android/feature/student/relay/src/main/java/com/sixkids/student/relay/pass/tagging/RelayNfc.kt @@ -0,0 +1,10 @@ +package com.sixkids.student.relay.pass.tagging + +import kotlinx.serialization.Serializable + +@Serializable +data class RelayNfc( + val relayId: Long, + val senderId: Long, + val question: String +) diff --git a/android/feature/student/relay/src/main/java/com/sixkids/student/relay/pass/tagging/receiver/RelayTaggingReceiverContract.kt b/android/feature/student/relay/src/main/java/com/sixkids/student/relay/pass/tagging/receiver/RelayTaggingReceiverContract.kt new file mode 100644 index 00000000..629eb284 --- /dev/null +++ b/android/feature/student/relay/src/main/java/com/sixkids/student/relay/pass/tagging/receiver/RelayTaggingReceiverContract.kt @@ -0,0 +1,18 @@ +package com.sixkids.student.relay.pass.tagging.receiver + +import com.sixkids.model.RelayReceive +import com.sixkids.student.relay.pass.tagging.RelayNfc +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState + +data class RelayTaggingReceiverState( + val isLoading: Boolean = false, + val relayId: Long = -1L, + val relayNfc: RelayNfc = RelayNfc(-1, -1, ""), + val relayReceive: RelayReceive = RelayReceive("", "", false, 0) +): UiState + +sealed interface RelayTaggingReceiverEffect: SideEffect { + data class OnShowSnackBar(val tkn: SnackbarToken): RelayTaggingReceiverEffect +} \ No newline at end of file diff --git a/android/feature/student/relay/src/main/java/com/sixkids/student/relay/pass/tagging/receiver/RelayTaggingReceiverScreen.kt b/android/feature/student/relay/src/main/java/com/sixkids/student/relay/pass/tagging/receiver/RelayTaggingReceiverScreen.kt new file mode 100644 index 00000000..dbb2ebea --- /dev/null +++ b/android/feature/student/relay/src/main/java/com/sixkids/student/relay/pass/tagging/receiver/RelayTaggingReceiverScreen.kt @@ -0,0 +1,126 @@ +package com.sixkids.student.relay.pass.tagging.receiver + +import android.app.Activity +import android.nfc.NfcAdapter +import android.nfc.Tag +import android.nfc.tech.IsoDep +import android.util.Log +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.sixkids.core.nfc.HCEService +import com.sixkids.designsystem.component.screen.RelayPassResultScreen +import com.sixkids.designsystem.component.screen.RelayTaggingScreen +import com.sixkids.designsystem.theme.Purple +import com.sixkids.model.RelayReceive +import com.sixkids.student.relay.pass.tagging.RelayNfc +import kotlinx.serialization.json.Json +import com.sixkids.designsystem.R as DesignSystemR + +private const val TAG = "D107" + +@Composable +fun RelayTaggingReceiverRoute( + viewModel: RelayTaggingReceiverViewModel = hiltViewModel(), + navigateToRelayHistory : () -> Unit +){ + val context = LocalContext.current + val activity = context as Activity + + val uiState = viewModel.uiState.collectAsStateWithLifecycle().value + val nfcAdapter: NfcAdapter = NfcAdapter.getDefaultAdapter(context) + + DisposableEffect(key1 = Unit) { + viewModel.init() + + onDispose { + nfcAdapter.disableReaderMode(activity) + } + } + + if (uiState.relayId != -1L && nfcAdapter.isEnabled) { + Log.d(TAG, "RelayTaggingReceiverRoute: Ready!") + nfcAdapter.enableReaderMode(activity, { tag : Tag? -> + tag?.let { + val isoDep = IsoDep.get(it) + isoDep.use { iso -> + iso.connect() + val response = isoDep.transceive(HCEService.SELECT_APDU) + val message = String(response.copyOfRange(0, response.size - 2)) + Log.d(TAG, "RelayTaggingReceiverRoute: $message") + val relayNfc = Json.decodeFromString(message) + viewModel.onNfcReceived(relayNfc) + } + } + }, NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK, null) + + } + + if (uiState.relayReceive.senderName != ""){ + RelayTaggingResultScreen(uiState.relayReceive, navigateToRelayHistory) + }else{ + RelayTaggingReceiverScreen() + } + +} + +@Composable +fun RelayTaggingReceiverScreen(){ + Box(modifier = Modifier.fillMaxSize()){ + RelayTaggingScreen( + isSender = false + ) + } +} + +@Composable +fun RelayTaggingResultScreen( + relayReceive: RelayReceive, + navigateToRelayHistory: () -> Unit = {} +){ + Box(modifier = Modifier.fillMaxSize()){ + if (!relayReceive.lastStatus){ + RelayPassResultScreen( + title = stringResource(id = DesignSystemR.string.relay_pass_result_title), + subTitle = stringResource(DesignSystemR.string.relay_pass_result_subtitle_receiver), + bodyTop = stringResource(id = DesignSystemR.string.relay_pass_result_body_top_receiver), + bodyMiddle = "${relayReceive.senderName} 학생이", + bodyBottom = stringResource(id = DesignSystemR.string.relay_pass_result_body_bottom_receiver), + onClick = {navigateToRelayHistory()} + ) + }else{ + RelayPassResultScreen( + title = stringResource(id = DesignSystemR.string.relay_pass_result_title_bomb), + subTitle = stringResource(DesignSystemR.string.relay_pass_result_subtitle_bomb, relayReceive.demerit), + bodyTop = "${relayReceive.senderName} 학생이", + bodyMiddle = "\'${relayReceive.question}\'", + bodyBottom = stringResource(id = DesignSystemR.string.relay_pass_result_body_bottom_sender), + imgRes = DesignSystemR.drawable.bomb, + backgroundColor = Purple, + onClick = {navigateToRelayHistory()} + ) + } + + } +} + +@Composable +@Preview(showBackground = true) +fun RelayTaggingReceiverScreenPreview() { + RelayTaggingReceiverScreen() +} + +@Composable +@Preview(showBackground = true) +fun RelayTaggingResultScreenPreview() { + RelayTaggingResultScreen( + RelayReceive("오하빈", "", true, 0) + ) +} \ No newline at end of file diff --git a/android/feature/student/relay/src/main/java/com/sixkids/student/relay/pass/tagging/receiver/RelayTaggingReceiverViewModel.kt b/android/feature/student/relay/src/main/java/com/sixkids/student/relay/pass/tagging/receiver/RelayTaggingReceiverViewModel.kt new file mode 100644 index 00000000..11eb31bc --- /dev/null +++ b/android/feature/student/relay/src/main/java/com/sixkids/student/relay/pass/tagging/receiver/RelayTaggingReceiverViewModel.kt @@ -0,0 +1,40 @@ +package com.sixkids.student.relay.pass.tagging.receiver + +import android.util.Log +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.sixkids.domain.usecase.relay.ReceiveRelayUseCase +import com.sixkids.student.relay.navigation.RelayRoute +import com.sixkids.student.relay.pass.tagging.RelayNfc +import com.sixkids.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +private const val TAG = "D107" +@HiltViewModel +class RelayTaggingReceiverViewModel @Inject constructor( + private val receiveRelayUseCase: ReceiveRelayUseCase, + savedStateHandle: SavedStateHandle +): BaseViewModel(RelayTaggingReceiverState()){ + private val receivedRelayId = savedStateHandle.get(RelayRoute.RELAY_ID_NAME) + + fun init() { + intent { copy(relayId = receivedRelayId?:-1L) } + } + + fun onNfcReceived(relayNfc: RelayNfc) { + if (relayNfc.relayId == receivedRelayId){ + viewModelScope.launch { + receiveRelayUseCase(relayNfc.relayId.toInt(), relayNfc.senderId, relayNfc.question) + .onSuccess { + intent { copy(relayReceive = it) } + } + .onFailure { + Log.e(TAG, "Failed to receive relay", it) + } + } + } + } + +} \ No newline at end of file diff --git a/android/feature/student/relay/src/main/java/com/sixkids/student/relay/pass/tagging/sender/RelayTaggingSenderContract.kt b/android/feature/student/relay/src/main/java/com/sixkids/student/relay/pass/tagging/sender/RelayTaggingSenderContract.kt new file mode 100644 index 00000000..8317901b --- /dev/null +++ b/android/feature/student/relay/src/main/java/com/sixkids/student/relay/pass/tagging/sender/RelayTaggingSenderContract.kt @@ -0,0 +1,18 @@ +package com.sixkids.student.relay.pass.tagging.sender + +import com.sixkids.model.RelaySend +import com.sixkids.student.relay.pass.tagging.RelayNfc +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState + +data class RelayTaggingSenderState( + val isLoading: Boolean = false, + val relayNfc: RelayNfc = RelayNfc(-1, -1, ""), + val relaySend: RelaySend = RelaySend(), +): UiState + +sealed interface RelayTaggingSenderEffect: SideEffect { + data class NavigateToTaggingResult(val prevMemberName: String, val prevQuestion: String): RelayTaggingSenderEffect + data class OnShowSnackBar(val tkn: SnackbarToken): RelayTaggingSenderEffect +} \ No newline at end of file diff --git a/android/feature/student/relay/src/main/java/com/sixkids/student/relay/pass/tagging/sender/RelayTaggingSenderScreen.kt b/android/feature/student/relay/src/main/java/com/sixkids/student/relay/pass/tagging/sender/RelayTaggingSenderScreen.kt new file mode 100644 index 00000000..05914438 --- /dev/null +++ b/android/feature/student/relay/src/main/java/com/sixkids/student/relay/pass/tagging/sender/RelayTaggingSenderScreen.kt @@ -0,0 +1,107 @@ +package com.sixkids.student.relay.pass.tagging.sender + +import android.util.Log +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.sixkids.core.nfc.HCEService +import com.sixkids.designsystem.R +import com.sixkids.designsystem.component.screen.RelayPassResultScreen +import com.sixkids.designsystem.component.screen.RelayTaggingScreen +import com.sixkids.model.RelaySend +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.extension.collectWithLifecycle +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +private const val TAG = "D107" + +@Composable +fun RelayTaggingSenderRoute( + viewModel: RelayTaggingSenderViewModel = hiltViewModel(), + navigateToRelayHistory: () -> Unit, + onShowSnackBar: (SnackbarToken) -> Unit +) { + val uiState = viewModel.uiState.collectAsStateWithLifecycle().value + + viewModel.sideEffect.collectWithLifecycle { sideEffect -> + when (sideEffect) { + is RelayTaggingSenderEffect.NavigateToTaggingResult -> navigateToRelayHistory() + is RelayTaggingSenderEffect.OnShowSnackBar -> onShowSnackBar(sideEffect.tkn) + } + } + + LaunchedEffect(key1 = Unit) { + viewModel.init() + } + + if (uiState.relaySend.prevMemberName == "" && uiState.relaySend.prevQuestion == "") { + RelayTaggingSenderScreen( + uiState, + checkRelaySent = viewModel::checkRelaySent + ) + } else { + RelayTaggingResultScreen( + relaySend = uiState.relaySend, + navigateToRelayHistory = navigateToRelayHistory + ) + } + + +} + +@Composable +fun RelayTaggingSenderScreen( + uiState: RelayTaggingSenderState = RelayTaggingSenderState(), + checkRelaySent: () -> Unit = {} +) { + if (uiState.relayNfc.relayId != -1L) { + val serializedRelayNfc = Json.encodeToString(uiState.relayNfc) + Log.d(TAG, "RelayTaggingSenderScreen: $serializedRelayNfc") + HCEService.setData(serializedRelayNfc) + } + + + Box(modifier = Modifier.fillMaxSize()) { + RelayTaggingScreen( + isSender = true, + onClick = checkRelaySent + ) + } +} + +@Composable +fun RelayTaggingResultScreen( + relaySend: RelaySend, + navigateToRelayHistory: () -> Unit = {} +) { + Box(modifier = Modifier.fillMaxSize()) { + RelayPassResultScreen( + title = stringResource(id = R.string.relay_pass_result_title), + subTitle = stringResource(R.string.relay_pass_result_subtitle_sender), + bodyTop = if (relaySend.prevMemberName != "") "${relaySend.prevMemberName} 학생이" + else relaySend.prevMemberName, + bodyMiddle = "\'${relaySend.prevQuestion}\'", + bodyBottom = if (relaySend.prevMemberName != "") stringResource(id = R.string.relay_pass_result_body_bottom_sender) + else "", + onClick = { navigateToRelayHistory() } + ) + } +} + +@Composable +@Preview(showBackground = true) +fun RelayTaggingSenderScreenPreview() { + RelayTaggingResultScreen( + relaySend = RelaySend( + prevMemberName = "김철수", + prevQuestion = "오늘 점심은 뭐먹지?" + ) + ) +} \ No newline at end of file diff --git a/android/feature/student/relay/src/main/java/com/sixkids/student/relay/pass/tagging/sender/RelayTaggingSenderViewModel.kt b/android/feature/student/relay/src/main/java/com/sixkids/student/relay/pass/tagging/sender/RelayTaggingSenderViewModel.kt new file mode 100644 index 00000000..dc8cafe3 --- /dev/null +++ b/android/feature/student/relay/src/main/java/com/sixkids/student/relay/pass/tagging/sender/RelayTaggingSenderViewModel.kt @@ -0,0 +1,68 @@ +package com.sixkids.student.relay.pass.tagging.sender + +import android.util.Log +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.sixkids.domain.usecase.relay.SendRelayUseCase +import com.sixkids.domain.usecase.user.LoadUserInfoUseCase +import com.sixkids.model.BadRequestException +import com.sixkids.model.NotFoundException +import com.sixkids.model.RelaySend +import com.sixkids.student.relay.navigation.RelayRoute +import com.sixkids.student.relay.pass.tagging.RelayNfc +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +private const val TAG = "D107" + +@HiltViewModel +class RelayTaggingSenderViewModel @Inject constructor( + private val loadUserInfoUseCase: LoadUserInfoUseCase, + private val sendRelayUseCase: SendRelayUseCase, + savedStateHandle: SavedStateHandle +) : BaseViewModel(RelayTaggingSenderState()) { + private val relayId = savedStateHandle.get(RelayRoute.RELAY_ID_NAME) + private val question = savedStateHandle.get(RelayRoute.RELAY_QUESTION_NAME) + + fun init() { + viewModelScope.launch { + loadUserInfoUseCase().onSuccess { + intent { copy(relayNfc = RelayNfc(relayId ?: -1, it.id.toLong(), question ?: "")) } + }.onFailure { + Log.d(TAG, "init: ${it.message}") + } + } + } + + fun checkRelaySent() { + viewModelScope.launch { + sendRelayUseCase(relayId?.toInt() ?: -1) + .onSuccess { + intent { copy(relaySend = it) } + }.onFailure { + when(it){ + is NotFoundException -> { + intent { copy(relaySend = RelaySend("", "첫번째 주자입니다!")) } + } + is BadRequestException -> { + postSideEffect( + RelayTaggingSenderEffect.OnShowSnackBar( + SnackbarToken("이어 달리기가 전달되지 않았어요. 다시 시도해 보세요") + ) + ) + } + else -> { + postSideEffect( + RelayTaggingSenderEffect.OnShowSnackBar( + SnackbarToken(it.message ?: "이어 달리기 전달에 실패했어요") + ) + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/android/feature/student/relay/src/main/java/com/sixkids/student/relay/result/RelayCreateResultContract.kt b/android/feature/student/relay/src/main/java/com/sixkids/student/relay/result/RelayCreateResultContract.kt new file mode 100644 index 00000000..30140c0d --- /dev/null +++ b/android/feature/student/relay/src/main/java/com/sixkids/student/relay/result/RelayCreateResultContract.kt @@ -0,0 +1,18 @@ +package com.sixkids.student.relay.result + +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState + +data class RelayCreateResultState( + val showResultDialog: Boolean = false, +) : UiState + + +sealed interface RelayCreateResultEffect : SideEffect { + data object NavigateToRelayHistory : RelayCreateResultEffect + + data class HandleException( + val throwable: Throwable, val retry: () -> Unit + ) : RelayCreateResultEffect + +} diff --git a/android/feature/student/relay/src/main/java/com/sixkids/student/relay/result/RelayCreateResultScreen.kt b/android/feature/student/relay/src/main/java/com/sixkids/student/relay/result/RelayCreateResultScreen.kt new file mode 100644 index 00000000..297ed439 --- /dev/null +++ b/android/feature/student/relay/src/main/java/com/sixkids/student/relay/result/RelayCreateResultScreen.kt @@ -0,0 +1,96 @@ +package com.sixkids.student.relay.result + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.sixkids.designsystem.component.card.UlbanMissionCard +import com.sixkids.designsystem.theme.Orange +import com.sixkids.designsystem.theme.UlbanTheme +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.student.relay.R +import com.sixkids.ui.extension.collectWithLifecycle +import com.sixkids.designsystem.R as DesignSystemR + +@Composable +fun RelayCreateResultRoute( + viewModel: RelayCreateResultViewModel = hiltViewModel(), + navigateToRelayHistory: () -> Unit, + handleException: (Throwable, () -> Unit) -> Unit +) { + viewModel.sideEffect.collectWithLifecycle { + when (it) { + is RelayCreateResultEffect.HandleException -> handleException(it.throwable, it.retry) + RelayCreateResultEffect.NavigateToRelayHistory -> navigateToRelayHistory() + } + } + + RelayCreateResultScreen( + onClickConfirm = viewModel::navigateToChallengeHistory + ) +} + + +@Composable +fun RelayCreateResultScreen( + paddingValues: PaddingValues = PaddingValues(32.dp), + onClickConfirm: () -> Unit = {} +) { + BackHandler { + onClickConfirm() + } + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.create_relay_success), + style = UlbanTypography.titleSmall, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.size(16.dp)) + UlbanMissionCard( + imgRes = DesignSystemR.drawable.relay, + title = "친구에게 전달해 봐요!", + backGroundColor = Orange + ) + Image( + modifier = Modifier + .size(160.dp) + .clickable { + onClickConfirm() + }, + painter = painterResource(id = R.drawable.relay_created_success), + contentDescription = "challenge success" + ) + } +} + + +@Preview(showBackground = true) +@Composable +fun PreviewResultContent() { + UlbanTheme { + RelayCreateResultScreen() + } +} diff --git a/android/feature/student/relay/src/main/java/com/sixkids/student/relay/result/RelayCreateResultViewModel.kt b/android/feature/student/relay/src/main/java/com/sixkids/student/relay/result/RelayCreateResultViewModel.kt new file mode 100644 index 00000000..827e8506 --- /dev/null +++ b/android/feature/student/relay/src/main/java/com/sixkids/student/relay/result/RelayCreateResultViewModel.kt @@ -0,0 +1,17 @@ +package com.sixkids.student.relay.result + +import com.sixkids.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class RelayCreateResultViewModel @Inject constructor( + +): BaseViewModel( + RelayCreateResultState() +){ + + fun navigateToChallengeHistory() { + postSideEffect(RelayCreateResultEffect.NavigateToRelayHistory) + } +} \ No newline at end of file diff --git a/android/feature/student/relay/src/main/res/drawable/relay_created_success.png b/android/feature/student/relay/src/main/res/drawable/relay_created_success.png new file mode 100644 index 00000000..52170aed Binary files /dev/null and b/android/feature/student/relay/src/main/res/drawable/relay_created_success.png differ diff --git a/android/feature/student/relay/src/main/res/values/strings.xml b/android/feature/student/relay/src/main/res/values/strings.xml new file mode 100644 index 00000000..23821d0b --- /dev/null +++ b/android/feature/student/relay/src/main/res/values/strings.xml @@ -0,0 +1,21 @@ + + + 이어 달리기 + 새로운\n이어 달리기\n만들기 + 이어 달리기\n답변하기 + 이어 달리기가\n진행 중입니다! + 질문에 답변하고\n친구에게 전달 해봐요! + 여기를 터치해서\n이어 달리기를 이어 받아 봐요! + 지금까지 %d번 이어 달리기를 진행했어요 + 기록이 없어요 + %s 학생이 폭탄을 터트렸어요 + %d명 학생이 참여했어요 + 이어 달리기 만들기 + 다음 친구가 대답할 질문을 작성해 주세요 + 질문을 작성해 주세요 + 만들기 + 새로운\n이어 달리기가 만들어졌습니다! + 이어 달리기 참여하기 + 다음 + 내가 받은 질문 + \ No newline at end of file diff --git a/android/feature/teacher/board/build.gradle.kts b/android/feature/teacher/board/build.gradle.kts index dddf7a76..14ffda68 100644 --- a/android/feature/teacher/board/build.gradle.kts +++ b/android/feature/teacher/board/build.gradle.kts @@ -1,10 +1,42 @@ +import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties +import java.util.Properties + plugins { alias(libs.plugins.sixkids.android.feature.compose) } +fun getProperty(propertyKey: String): String = + gradleLocalProperties(rootDir, providers).getProperty(propertyKey) + android { namespace = "com.sixkids.teacher.board" + + defaultConfig { + val localProperties = Properties() + val localPropertiesFile = rootProject.file("local.properties") + if (localPropertiesFile.exists()) { + localProperties.load(localPropertiesFile.inputStream()) + } + + val stompUrl = localProperties.getProperty("STOMP_ENDPOINT") ?: "" + + buildConfigField("String", "STOMP_ENDPOINT", "\"${stompUrl}\"") + } + + buildFeatures { + buildConfig = true + } } dependencies { + implementation(libs.okhttp) + implementation(libs.okhttp.logginginterceptor) + + implementation(libs.moshi.kotlin) + implementation(libs.moshi.converter) + implementation(libs.krossbow.stomp. core) + implementation(libs.krossbow.websocket.okhttp) + implementation(libs.krossbow.stomp.moshi) + + implementation(libs.bundles.paging) } diff --git a/android/feature/teacher/board/src/main/AndroidManifest.xml b/android/feature/teacher/board/src/main/AndroidManifest.xml index a5918e68..bf8960fd 100644 --- a/android/feature/teacher/board/src/main/AndroidManifest.xml +++ b/android/feature/teacher/board/src/main/AndroidManifest.xml @@ -1,4 +1,8 @@ + + + \ No newline at end of file diff --git a/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/announce/announcedetail/AnnounceDetailContract.kt b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/announce/announcedetail/AnnounceDetailContract.kt new file mode 100644 index 00000000..76ef3a05 --- /dev/null +++ b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/announce/announcedetail/AnnounceDetailContract.kt @@ -0,0 +1,17 @@ +package com.sixkids.teacher.board.announce.announcedetail + +import com.sixkids.model.PostDetail +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState + +sealed interface AnnounceDetailEffect : SideEffect { + data object RefreshAnnounceDetail : AnnounceDetailEffect + data class OnShowSnackbar(val message: String) : AnnounceDetailEffect +} + +data class AnnounceDetailState( + val isLoading: Boolean = false, + val postDetail: PostDetail = PostDetail(), + val commentText: String = "", + val selectedCommentId: Long? = null, +) : UiState \ No newline at end of file diff --git a/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/announce/announcedetail/AnnounceDetailScreen.kt b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/announce/announcedetail/AnnounceDetailScreen.kt new file mode 100644 index 00000000..6a9ee7d0 --- /dev/null +++ b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/announce/announcedetail/AnnounceDetailScreen.kt @@ -0,0 +1,227 @@ +package com.sixkids.teacher.board.announce.announcedetail + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import coil.compose.AsyncImage +import com.sixkids.designsystem.R +import com.sixkids.designsystem.component.screen.LoadingScreen +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.model.MemberSimple +import com.sixkids.model.PostDetail +import com.sixkids.teacher.board.post.postdetail.commentDummy +import com.sixkids.teacher.board.post.postdetail.component.CommentItem +import com.sixkids.teacher.board.post.postdetail.component.CommentTextField +import com.sixkids.teacher.board.post.postdetail.component.PostWriterInfo +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.util.formatToMonthDayTime +import java.time.LocalDateTime + +@Composable +fun AnnounceDetailRoute( + viewModel: AnnounceDetailViewModel = hiltViewModel(), + padding: PaddingValues, + onShowSnackBar: (SnackbarToken) -> Unit +) { + val uiState by viewModel.uiState.collectAsState() + // 키보드 숨기기 + var keyboardHideState by remember { mutableStateOf(false) } + if (keyboardHideState) { + LocalSoftwareKeyboardController.current?.hide() + keyboardHideState = false + } + + LaunchedEffect(Unit) { + viewModel.getAnnounceDetail() + } + + LaunchedEffect(viewModel.sideEffect) { + viewModel.sideEffect.collect { sideEffect -> + when (sideEffect) { + AnnounceDetailEffect.RefreshAnnounceDetail -> { + keyboardHideState = true + viewModel.getAnnounceDetail() + } + + is AnnounceDetailEffect.OnShowSnackbar -> { + onShowSnackBar(SnackbarToken(message = sideEffect.message)) + } + } + } + } + + Box(modifier = Modifier.padding(padding)) { + AnnounceDetailScreen( + announceDetailState = uiState, + onCommentTextChanged = viewModel::onCommentTextChanged, + onClickComment = viewModel::onSelectedCommentId, + onClickSubmitComment = viewModel::onNewComment, + ) + } +} + +@Composable +fun AnnounceDetailScreen( + modifier: Modifier = Modifier, + announceDetailState: AnnounceDetailState, + onCommentTextChanged: (String) -> Unit = {}, + onClickComment: (Long) -> Unit = {}, + onClickSubmitComment: () -> Unit = {}, + postDeleteOnclick: () -> Unit = {} +) { + + val scrollState = rememberScrollState() + + BackHandler( + enabled = announceDetailState.selectedCommentId != null, + onBack = { onClickComment(announceDetailState.selectedCommentId ?: 0) } + ) + + Box { + Column { + Column( + modifier = modifier + .weight(1f) + .padding(20.dp) + .verticalScroll(scrollState), + ) { + // 작성자 정보 + Row( + verticalAlignment = Alignment.CenterVertically + ) { + PostWriterInfo( + height = 60.dp, + writer = announceDetailState.postDetail.writeMember.name, + dateString = announceDetailState.postDetail.createTime.formatToMonthDayTime(), + writerImageUrl = announceDetailState.postDetail.writeMember.photo + ) + Spacer(modifier = Modifier.weight(1f)) + Icon( + modifier = Modifier + .size(30.dp) + .clickable { postDeleteOnclick() }, + imageVector = ImageVector.vectorResource(id = R.drawable.ic_delete), + contentDescription = "더보기" + ) + } + + Spacer(modifier = Modifier.height(10.dp)) + Text( + text = announceDetailState.postDetail.title, + style = UlbanTypography.titleLarge + ) + Spacer(modifier = Modifier.height(10.dp)) + // 이미지 + if (announceDetailState.postDetail.imageUri.isNotEmpty()) { + AsyncImage( + model = announceDetailState.postDetail.imageUri, + contentDescription = null, + contentScale = ContentScale.Crop, + ) + } + Spacer(modifier = Modifier.height(10.dp)) + Text( + text = announceDetailState.postDetail.content, + style = UlbanTypography.bodyLarge + ) + Spacer(modifier = Modifier.height(10.dp)) + HorizontalDivider( + thickness = 2.dp, + color = Color.Black + ) + // 댓글 목록 + for (comment in announceDetailState.postDetail.comments) { + CommentItem( + selected = announceDetailState.selectedCommentId == comment.id, + writer = comment.member.name, + dateString = comment.createTime.formatToMonthDayTime(), + writerImageUrl = comment.member.photo, + commentString = comment.content, + recommentOnclick = { + onClickComment(comment.id) + } + ) + // 대댓글 목록 + for (recomment in comment.recomments) { + Row { + Icon( + modifier = Modifier.padding(4.dp), + imageVector = ImageVector.vectorResource(id = R.drawable.ic_recomment), + contentDescription = null + ) + CommentItem( + writer = recomment.member.name, + dateString = recomment.createTime.formatToMonthDayTime(), + writerImageUrl = recomment.member.photo, + commentString = recomment.content, + isRecomment = true + ) + } + + } + } + } + CommentTextField( + msg = announceDetailState.commentText, + onTextIuputChange = onCommentTextChanged, + onSendClick = { onClickSubmitComment() } + ) + } + + + if (announceDetailState.isLoading) { + LoadingScreen() + } + } +} + +@Preview(showBackground = true) +@Composable +fun AnnounceDetailScreenPreview() { + AnnounceDetailScreen( + announceDetailState = AnnounceDetailState( + postDetail = PostDetail( + title = "제목", + content = "내용내용내용내용내용내용내용내용내용내용내용내용내용", + writeMember = MemberSimple( + id = 1, + name = "작성자", + photo = "https://picsum.photos/200/300" + ), + createTime = LocalDateTime.now(), + imageUri = "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTKe3bkhl96AgtmHyTiKW-KXRst2-5MoY6xB9-mZP74BQ&s", + comments = listOf(commentDummy, commentDummy) + ) + ) + ) +} + diff --git a/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/announce/announcedetail/AnnounceDetailViewModel.kt b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/announce/announcedetail/AnnounceDetailViewModel.kt new file mode 100644 index 00000000..dcf83826 --- /dev/null +++ b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/announce/announcedetail/AnnounceDetailViewModel.kt @@ -0,0 +1,101 @@ +package com.sixkids.teacher.board.announce.announcedetail + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.sixkids.domain.usecase.comment.DeleteCommentUseCase +import com.sixkids.domain.usecase.comment.NewCommentUseCase +import com.sixkids.domain.usecase.comment.NewRecommentUseCase +import com.sixkids.domain.usecase.comment.ReportCommentUseCase +import com.sixkids.domain.usecase.comment.UpdateCommentUsecase +import com.sixkids.domain.usecase.post.DeletePostUseCase +import com.sixkids.domain.usecase.post.GetPostDetailUseCase +import com.sixkids.domain.usecase.post.UpdatePostUseCase +import com.sixkids.teacher.board.navigation.BoardRoute +import com.sixkids.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class AnnounceDetailViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val getPostDetailUseCase: GetPostDetailUseCase, + private val updatePostUseCase: UpdatePostUseCase, + private val deletePostUsecase: DeletePostUseCase, + private val deleteCommentUseCase: DeleteCommentUseCase, + private val updateCommentUsecase: UpdateCommentUsecase, + private val newCommentUseCase: NewCommentUseCase, + private val newRecommentUseCase: NewRecommentUseCase, + private val reportCommentUseCase: ReportCommentUseCase +) : BaseViewModel(AnnounceDetailState()){ + + private val postId: Long = savedStateHandle.get(BoardRoute.announceDetailARG)!! + + fun onCommentTextChanged(commentText: String) = intent { copy(commentText = commentText) } + fun onSelectedCommentId(commentId: Long?) = intent { + if (currentState.selectedCommentId == commentId) { + copy(selectedCommentId = null) + } else { + copy(selectedCommentId = commentId) + } + } + + fun getAnnounceDetail() { + viewModelScope.launch { + intent { copy(isLoading = true) } + getPostDetailUseCase(postId).onSuccess { + intent { copy(postDetail = it) } + }.onFailure { + postSideEffect(AnnounceDetailEffect.OnShowSnackbar(it.message ?: "게시글을 불러오지 못했어요")) + } + intent { copy(isLoading = false) } + } + } + + fun onNewComment() { + if (currentState.commentText.isBlank()) { + postSideEffect(AnnounceDetailEffect.OnShowSnackbar("댓글을 입력해주세요")) + } else { + if (currentState.selectedCommentId == null) { + viewModelScope.launch { + intent { copy(isLoading = true) } + newCommentUseCase( + postId = postId, + content = currentState.commentText, + ).onSuccess { + postSideEffect(AnnounceDetailEffect.OnShowSnackbar("댓글이 작성되었습니다")) + intent { copy(commentText = "", selectedCommentId = null) } + getAnnounceDetail() + }.onFailure { + postSideEffect( + AnnounceDetailEffect.OnShowSnackbar( + it.message ?: "댓글 작성에 실패했어요" + ) + ) + } + intent { copy(isLoading = false) } + } + } else { + viewModelScope.launch { + intent { copy(isLoading = true) } + newRecommentUseCase( + postId = postId, + content = currentState.commentText, + currentState.selectedCommentId!! + ).onSuccess { + postSideEffect(AnnounceDetailEffect.OnShowSnackbar("댓글이 작성되었습니다")) + intent { copy(commentText = "", selectedCommentId = null)} + getAnnounceDetail() + }.onFailure { + postSideEffect( + AnnounceDetailEffect.OnShowSnackbar( + it.message ?: "댓글 작성에 실패했어요" + ) + ) + } + intent { copy(isLoading = false) } + } + } + } + } +} \ No newline at end of file diff --git a/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/announce/announcelist/AnnounceListContract.kt b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/announce/announcelist/AnnounceListContract.kt new file mode 100644 index 00000000..f2fc1cc6 --- /dev/null +++ b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/announce/announcelist/AnnounceListContract.kt @@ -0,0 +1,17 @@ +package com.sixkids.teacher.board.announce.announcelist + +import com.sixkids.model.Post +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState + +sealed interface AnnounceListEffect : SideEffect{ + data object NavigateToAnnounceDetail: AnnounceListEffect + data object NavigateToWriteAnnounce: AnnounceListEffect + data class OnShowSnackBar(val message : String) : AnnounceListEffect +} + +data class AnnounceListState( + val isLoding: Boolean = false, + val classString: String = "", + val postList: List = emptyList(), +): UiState \ No newline at end of file diff --git a/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/announce/announcelist/AnnounceListScreen.kt b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/announce/announcelist/AnnounceListScreen.kt new file mode 100644 index 00000000..196b870f --- /dev/null +++ b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/announce/announcelist/AnnounceListScreen.kt @@ -0,0 +1,158 @@ +package com.sixkids.teacher.board.announce.announcelist + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import com.sixkids.designsystem.R +import com.sixkids.designsystem.component.appbar.UlbanDefaultAppBar +import com.sixkids.designsystem.component.appbar.UlbanDetailAppBar +import com.sixkids.designsystem.component.button.EditFAB +import com.sixkids.designsystem.component.screen.LoadingScreen +import com.sixkids.designsystem.theme.Orange +import com.sixkids.designsystem.theme.OrangeDark +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.designsystem.theme.Yellow +import com.sixkids.model.Post +import com.sixkids.teacher.board.post.postlist.component.PostItem +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.util.formatToMonthDayTimeKorean + +@Composable +fun AnnounceListRoute( + viewModel: AnnounceListViewModel = hiltViewModel(), + navigateToAnnounceDetail: (postId:Long) -> Unit, + navigateToAnnounceWrite: () -> Unit, + onShowSnackBar: (SnackbarToken) -> Unit, + padding: PaddingValues +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.getAnnounceList() + } + + LaunchedEffect(key1 = viewModel.sideEffect) { + viewModel.sideEffect.collect { sideEffect -> + when (sideEffect) { + AnnounceListEffect.NavigateToAnnounceDetail -> {} + AnnounceListEffect.NavigateToWriteAnnounce -> {} + is AnnounceListEffect.OnShowSnackBar -> { + onShowSnackBar(SnackbarToken(message = sideEffect.message)) + } + } + } + } + + Box( + modifier = Modifier + .padding(padding) + .fillMaxSize() + ) { + AnnounceListScreen( + announceListState = uiState, + postItems = viewModel.postList?.collectAsLazyPagingItems(), + postItemOnclick = navigateToAnnounceDetail, + fabClick = navigateToAnnounceWrite + ) + } +} + +@Composable +fun AnnounceListScreen( + modifier: Modifier = Modifier, + announceListState: AnnounceListState = AnnounceListState(), + postItems: LazyPagingItems? = null, + postItemOnclick: (postId: Long) -> Unit = {}, + fabClick: () -> Unit = {} +) { + val listState = rememberLazyListState() + + Box( + modifier = modifier + .fillMaxSize() + ) { + Column( + + ) { + + UlbanDefaultAppBar( + leftIcon = R.drawable.announce, + title = stringResource(id = com.sixkids.teacher.board.R.string.board_main_announce), + content = stringResource(id = com.sixkids.teacher.board.R.string.board_main_announce), + body = announceListState.classString.replace("\n", " "), + color = Orange + ) + + if (postItems != null){ + if (postItems.itemCount == 0){ + Spacer(modifier = Modifier.weight(1f)) + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = com.sixkids.teacher.board.R.string.board_announce_no_item), + textAlign = TextAlign.Center, + style = UlbanTypography.bodyLarge, + ) + Spacer(modifier = Modifier.weight(1f)) + } else { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + state = listState, + ) { + items(postItems.itemCount) { index -> + postItems[index]?.let { post -> + PostItem( + title = post.title, + writer = post.writer, + dateString = post.time.formatToMonthDayTimeKorean(), + commentCount = post.commentCount, + dividerColor = OrangeDark, + onClick = { postItemOnclick(post.id) } + ) + } + } + } + } + } + } + //FAB + EditFAB( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(16.dp), + buttonColor = Orange, + iconColor = OrangeDark, + onClick = fabClick + ) + if (announceListState.isLoding){ + LoadingScreen() + } + } +} + +@Preview(showBackground = true) +@Composable +fun AnnounceListScreenPreview() { + AnnounceListScreen() +} \ No newline at end of file diff --git a/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/announce/announcelist/AnnounceListViewModel.kt b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/announce/announcelist/AnnounceListViewModel.kt new file mode 100644 index 00000000..53126b21 --- /dev/null +++ b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/announce/announcelist/AnnounceListViewModel.kt @@ -0,0 +1,53 @@ +package com.sixkids.teacher.board.announce.announcelist + +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn +import com.sixkids.domain.usecase.organization.GetSelectedOrganizationIdUseCase +import com.sixkids.domain.usecase.organization.LoadSelectedOrganizationNameUseCase +import com.sixkids.domain.usecase.post.GetPostListUseCase +import com.sixkids.model.Post +import com.sixkids.model.PostCategory +import com.sixkids.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class AnnounceListViewModel @Inject constructor( + private val getPostListUseCase: GetPostListUseCase, + private val getSelectedOrganizationIdUseCase: GetSelectedOrganizationIdUseCase, + private val loadSelectedOrganizationNameUseCase: LoadSelectedOrganizationNameUseCase +): BaseViewModel(AnnounceListState()){ + private var organizationId: Int? = null + + var postList: Flow>? = null + + fun getAnnounceList() { + viewModelScope.launch { + intent { copy(isLoding = true) } + + loadSelectedOrganizationNameUseCase().onSuccess { + intent { copy(classString = it) } + }.onFailure { + intent { copy(classString = "") } + } + + if (organizationId == null){ + organizationId = getSelectedOrganizationIdUseCase().getOrNull() + } + + if (organizationId != null){ + postList = getPostListUseCase( + organizationId = organizationId!!, + postCategory = PostCategory.NOTICE + ).cachedIn(viewModelScope) + } else { + postSideEffect(AnnounceListEffect.OnShowSnackBar("학급 정보를 불러오지 못했어요 ;(")) + } + + intent { copy(isLoding = false) } + } + } +} \ No newline at end of file diff --git a/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/announce/announcewrite/AnnounceWriteContract.kt b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/announce/announcewrite/AnnounceWriteContract.kt new file mode 100644 index 00000000..e6d9b992 --- /dev/null +++ b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/announce/announcewrite/AnnounceWriteContract.kt @@ -0,0 +1,19 @@ +package com.sixkids.teacher.board.announce.announcewrite + +import android.graphics.Bitmap +import com.sixkids.teacher.board.post.postwrite.PostWriteEffect +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState + +sealed interface AnnounceWriteEffect: SideEffect{ + data object NavigateBack : AnnounceWriteEffect + data class OnShowSnackbar(val message: String) : AnnounceWriteEffect +} + +data class AnnounceWriteState( + val isLoading: Boolean = false, + val title: String = "", + val content: String = "", + val anonymousChecked: Boolean = false, + val photo: Bitmap? = null +): UiState \ No newline at end of file diff --git a/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/announce/announcewrite/AnnounceWriteScreen.kt b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/announce/announcewrite/AnnounceWriteScreen.kt new file mode 100644 index 00000000..df0b57da --- /dev/null +++ b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/announce/announcewrite/AnnounceWriteScreen.kt @@ -0,0 +1,240 @@ +package com.sixkids.teacher.board.announce.announcewrite + +import android.graphics.ImageDecoder +import android.os.Build +import android.provider.MediaStore +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +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.wrapContentHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.sixkids.designsystem.component.button.UlbanFilledButton +import com.sixkids.designsystem.component.screen.LoadingScreen +import com.sixkids.designsystem.theme.GrayLight +import com.sixkids.designsystem.theme.Orange +import com.sixkids.designsystem.theme.OrangeDark +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.teacher.board.R +import com.sixkids.teacher.board.post.postwrite.component.PageTitle +import com.sixkids.teacher.board.post.postwrite.saveBitmapToFile +import com.sixkids.ui.SnackbarToken +import java.io.IOException + +@Composable +fun AnnounceWriteRoute( + viewModel: AnnounceWriteViewModel = hiltViewModel(), + padding: PaddingValues, + navigateBack: () -> Unit, + onShowSnackBar: (SnackbarToken) -> Unit +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + val context = LocalContext.current + val photoLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickVisualMedia() + ) { uri -> + uri?.let { + try { + val bitmap = if (Build.VERSION.SDK_INT < 28) { + MediaStore.Images.Media.getBitmap(context.contentResolver, it) + } else { + ImageDecoder.decodeBitmap( + ImageDecoder.createSource( + context.contentResolver, + it + ) + ) + } + viewModel.onAddPhoto(bitmap) + } catch (e: IOException) { + viewModel.showToast("사진 호출에 실패했습니다.") + } + } + } + + LaunchedEffect(key1 = viewModel.sideEffect) { + viewModel.sideEffect.collect { sideEffect -> + when (sideEffect) { + AnnounceWriteEffect.NavigateBack -> navigateBack() + is AnnounceWriteEffect.OnShowSnackbar -> { + onShowSnackBar(SnackbarToken(message = sideEffect.message)) + } + } + } + } + + AnnounceWriteScreen( + announceWriteState = uiState, + cancelOnClick = { viewModel.onBack() }, + submitOnClick = { + viewModel.onPostAnnounce( + uiState.photo?.let { saveBitmapToFile(context, it, "post_photo.jpg") } + ) + }, + titleValueChange = { viewModel.onTitleChanged(it) }, + contentValueChange = { viewModel.onContentChanged(it) }, + addPhotoOnClick = { photoLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) } + ) +} + +@Composable +fun AnnounceWriteScreen( + modifier: Modifier = Modifier, + announceWriteState: AnnounceWriteState = AnnounceWriteState(), + cancelOnClick: () -> Unit = {}, + submitOnClick: () -> Unit = {}, + titleValueChange: (String) -> Unit = {}, + contentValueChange: (String) -> Unit = {}, + addPhotoOnClick: () -> Unit = {} +) { + + val scrollState = rememberScrollState() + + LaunchedEffect(announceWriteState.content) { + scrollState.scrollTo(scrollState.maxValue) + } + + Box { + Column( + modifier = modifier + .fillMaxSize() + .padding(20.dp) + ) { + Spacer(modifier = Modifier.height(20.dp)) + PageTitle( + title = stringResource(id = R.string.board_announce_write_title), + cancelOnclick = cancelOnClick + ) + //title + OutlinedTextField( + value = announceWriteState.title, + onValueChange = titleValueChange, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent + ), + placeholder = { + Text( + text = stringResource(id = R.string.board_write_content_title), + style = UlbanTypography.bodyLarge.copy( + color = Color.Gray + ) + ) + }, + textStyle = UlbanTypography.bodyLarge + ) + HorizontalDivider( + thickness = 2.dp, + color = Color.Black + ) + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .verticalScroll(scrollState) + ) { + //photo + if (announceWriteState.photo != null) { + Spacer(modifier = Modifier.height(10.dp)) + Image( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f), + bitmap = announceWriteState.photo.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Crop + ) + } + //content + OutlinedTextField( + value = announceWriteState.content, + onValueChange = { string -> + contentValueChange(string) + }, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent + ), + placeholder = { + Text( + text = stringResource(id = R.string.board_write_content_content), + style = UlbanTypography.bodyLarge.copy( + color = Color.Gray + ) + ) + }, + textStyle = UlbanTypography.bodyLarge + ) + } + Row( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(), + verticalAlignment = Alignment.CenterVertically + ) { + // 이미지 추가 아이콘 + Icon( + modifier = Modifier + .size(40.dp) + .clickable(onClick = addPhotoOnClick), + imageVector = ImageVector.vectorResource(id = com.sixkids.designsystem.R.drawable.ic_photo_camera), + contentDescription = null + ) + Spacer(modifier = Modifier.weight(1f)) + // 등록 버튼 + UlbanFilledButton( + text = stringResource(id = R.string.board_write_submit), + onClick = submitOnClick, + color = Orange, + textColor = OrangeDark + ) + } + } + + if (announceWriteState.isLoading) { + LoadingScreen() + } + } + +} + +@Preview(showBackground = true) +@Composable +fun AnnounceWriteScreenPreview() { + AnnounceWriteScreen() +} diff --git a/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/announce/announcewrite/AnnounceWriteViewModel.kt b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/announce/announcewrite/AnnounceWriteViewModel.kt new file mode 100644 index 00000000..d63d6869 --- /dev/null +++ b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/announce/announcewrite/AnnounceWriteViewModel.kt @@ -0,0 +1,64 @@ +package com.sixkids.teacher.board.announce.announcewrite + +import android.graphics.Bitmap +import androidx.lifecycle.viewModelScope +import com.sixkids.domain.usecase.organization.GetSelectedOrganizationIdUseCase +import com.sixkids.domain.usecase.post.NewPostUseCase +import com.sixkids.model.PostCategory +import com.sixkids.teacher.board.post.postwrite.PostWriteEffect +import com.sixkids.ui.base.BaseViewModel +import com.sixkids.ui.util.formatToMonthDayKorean +import com.sixkids.ui.util.formatToMonthDayTime +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import java.io.File +import java.time.LocalDateTime +import javax.inject.Inject + +@HiltViewModel +class AnnounceWriteViewModel @Inject constructor( + private val newPostUseCase: NewPostUseCase, + private val getSelectedOrganizationIdUseCase: GetSelectedOrganizationIdUseCase +): BaseViewModel(AnnounceWriteState( + title = LocalDateTime.now().formatToMonthDayKorean() +)){ + + private var organizationId: Int? = null + + fun onBack() = postSideEffect(AnnounceWriteEffect.NavigateBack) + fun onTitleChanged(title: String) = intent { copy(title = title) } + fun onContentChanged(content: String) = intent { copy(content = content) } + fun onAddPhoto(bitmap: Bitmap) = intent { copy(photo = bitmap) } + fun showToast(message: String) = postSideEffect(AnnounceWriteEffect.OnShowSnackbar(message)) + + fun onPostAnnounce(photo: File?) { + viewModelScope.launch { + intent { copy(isLoading = true) } + + if (organizationId == null) { + organizationId = getSelectedOrganizationIdUseCase().getOrNull() + } + + if (organizationId != null) { + newPostUseCase( + organizationId = organizationId!!.toLong(), + title = currentState.title, + content = currentState.content, + secretStatus = currentState.anonymousChecked, + postCategory = PostCategory.NOTICE, + file = photo + ).onSuccess { + postSideEffect(AnnounceWriteEffect.OnShowSnackbar("알림장 작성에 성공했어요 :)")) + postSideEffect(AnnounceWriteEffect.NavigateBack) + }.onFailure { + postSideEffect(AnnounceWriteEffect.OnShowSnackbar(it.message ?: "알림장 작성에 실패했어요 ;(")) + } + } else { + postSideEffect(AnnounceWriteEffect.OnShowSnackbar("학급 정보를 불러오지 못했어요 ;(")) + postSideEffect(AnnounceWriteEffect.NavigateBack) + } + + intent { copy(isLoading = false) } + } + } +} \ No newline at end of file diff --git a/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/chatting/ChattingContract.kt b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/chatting/ChattingContract.kt new file mode 100644 index 00000000..4dbeedff --- /dev/null +++ b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/chatting/ChattingContract.kt @@ -0,0 +1,18 @@ +package com.sixkids.teacher.board.chatting + +import com.sixkids.model.Chat +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState + +sealed interface ChattingSideEffect : SideEffect{ + +} + +data class ChattingState( + val isLoading : Boolean = false, + val organizationName: String = "", + val memberCount : Int = 0, + val memberId: Int = 0, + val message: String = "", + val chatList: List = emptyList() +) : UiState \ No newline at end of file diff --git a/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/chatting/ChattingScreen.kt b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/chatting/ChattingScreen.kt new file mode 100644 index 00000000..317ddecc --- /dev/null +++ b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/chatting/ChattingScreen.kt @@ -0,0 +1,359 @@ +package com.sixkids.teacher.board.chatting + +import android.annotation.SuppressLint +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.Send +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +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 androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import coil.compose.AsyncImage +import com.sixkids.designsystem.component.textfield.UlbanBasicTextField +import com.sixkids.designsystem.theme.Cream +import com.sixkids.designsystem.theme.Gray +import com.sixkids.designsystem.theme.GrayLight +import com.sixkids.designsystem.theme.UlbanTheme +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.designsystem.theme.Yellow +import com.sixkids.model.Chat +import com.sixkids.teacher.board.R +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.extension.collectWithLifecycle +import java.text.SimpleDateFormat +import java.util.TimeZone +import com.sixkids.designsystem.R as DesignSystemR + +private const val TAG = "D107" + +@Composable +fun ChattingRoute( + viewModel: ChattingViewModel = hiltViewModel(), + onBackClick: () -> Unit, + onShowSnackBar: (SnackbarToken) -> Unit +) { + val uiState = viewModel.uiState.collectAsStateWithLifecycle().value + + viewModel.sideEffect.collectWithLifecycle { sideEffect -> + when (sideEffect) { + else -> {} + } + } + + DisposableEffect(key1 = Unit) { + viewModel.initStomp() + + onDispose { + viewModel.cancelStomp() + } + } + + ChattingScreen( + uiState = uiState, + onUpdateMessage = viewModel::updateMessage, + onBackClick = onBackClick, + onSendClick = viewModel::sendMessage, + onPhotoClick = { + //사진 + }, + chatItems = viewModel.originalChatList?.collectAsLazyPagingItems() + ) +} + +@Composable +fun ChattingScreen( + uiState: ChattingState = ChattingState(), + onUpdateMessage: (String) -> Unit = {}, + onBackClick: () -> Unit = {}, + onSendClick: (String) -> Unit = {}, + onPhotoClick: () -> Unit = {}, + chatItems: LazyPagingItems? = null +) { + Box( + modifier = Modifier + .fillMaxSize() + ) { + Column { + TopSection( + uiState.organizationName, + uiState.memberCount, onBackClick + ) + + ChatSection( + uiState.memberId, + chatItems = chatItems, + uiState.chatList, + modifier = Modifier + .weight(1f) + .fillMaxSize() + ) + + InputSection( + msg = uiState.message, + onUpdateMessage = onUpdateMessage, + onSendClick = onSendClick, + onPhotoClick = onPhotoClick + ) + } + } +} + +@Composable +fun TopSection( + organizationName: String = "", + memberCount: Int = 0, + onBackClick: () -> Unit = {} +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(id = DesignSystemR.drawable.ic_arrow_back), + contentDescription = "back button", + modifier = Modifier.clickable { onBackClick() } + ) + + Text( + text = organizationName.replace("\n", " "), + style = UlbanTypography.titleSmall, + modifier = Modifier.padding(10.dp, 0.dp) + ) + + } +} + +@Composable +fun ChatSection( + memberId: Int, + chatItems: LazyPagingItems? = null, + chatList: List, + modifier: Modifier = Modifier +) { +// Log.d(TAG, "ChatSection: ") + val scrollState = rememberLazyListState() + + if (chatItems == null) { + Text(text = "데이터 없음") + } else { + Column(modifier = modifier) { + LazyColumn( + state = scrollState, modifier = Modifier.weight(1f), + + ) { + items(chatItems.itemCount) { idx -> + if (chatItems[idx]?.memberId == memberId.toLong()) { + MyChat(chatItems[idx]!!) + } else { + OtherChat(chatItems[idx]!!) + } + } + + items(chatList) { chat -> + if (chat.memberId == memberId.toLong()) { + MyChat(chat) + } else { + OtherChat(chat) + } + } + + + } + } + } + val serverChatSize = chatItems?.itemCount ?: 0 + val socketChatSize = chatList.size + val totalChatSize = serverChatSize + socketChatSize + + if (totalChatSize > 0) { + LaunchedEffect(totalChatSize) { + scrollState.scrollToItem(totalChatSize - 1) + } + } +} + +@Composable +fun OtherChat(chat: Chat) { + val screenWidthDp = with(LocalDensity.current) { + LocalContext.current.resources.displayMetrics.widthPixels.toDp() + } + val maxWidthDp = screenWidthDp * 0.6f + Column( + modifier = Modifier + .fillMaxWidth() + .padding(6.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + AsyncImage( + model = chat.memberImageUrl, + contentDescription = "profile photo", + modifier = Modifier + .size(32.dp) + .clip(RoundedCornerShape(4.dp)), + contentScale = ContentScale.Crop + ) + Text( + text = chat.memberName, + style = UlbanTypography.bodySmall, + modifier = Modifier.padding(10.dp) + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp, 0.dp, 0.dp, 10.dp), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.Bottom + ) { + + Text( + text = chat.content, + style = UlbanTypography.bodyMedium.copy(fontWeight = FontWeight.Normal), + modifier = Modifier + .background(GrayLight, shape = RoundedCornerShape(10.dp)) + .padding(12.dp) + .widthIn(max = maxWidthDp) + .wrapContentWidth() + ) + Text( + text = chatTimeFormat(chat.sendDateTime), + style = UlbanTypography.bodySmall.copy( + fontSize = 8.sp, + lineHeight = 10.sp, + fontWeight = FontWeight.Normal + ), + modifier = Modifier.padding(4.dp, 0.dp, 0.dp, 6.dp) + ) + } + } +} + +@Composable +fun MyChat(chat: Chat) { + val screenWidthDp = with(LocalDensity.current) { + LocalContext.current.resources.displayMetrics.widthPixels.toDp() + } + val maxWidthDp = screenWidthDp * 0.6f + Row( + modifier = Modifier + .fillMaxWidth() + .padding(6.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.Bottom + ) { + Text( + text = chatTimeFormat(chat.sendDateTime), + style = UlbanTypography.bodySmall.copy( + fontSize = 8.sp, + lineHeight = 10.sp, + fontWeight = FontWeight.Normal + ), + modifier = Modifier.padding(0.dp, 0.dp, 4.dp, 6.dp) + ) + Text( + text = chat.content, + style = UlbanTypography.bodyMedium.copy(fontWeight = FontWeight.Normal), + modifier = Modifier + .background(Yellow, shape = RoundedCornerShape(10.dp)) + .padding(12.dp) + .widthIn(max = maxWidthDp) + ) + } +} + +@SuppressLint("SimpleDateFormat") +fun chatTimeFormat(time: Long): String { + val formatter = SimpleDateFormat("MM/dd\na h:mm").apply { + timeZone = TimeZone.getTimeZone("Asia/Seoul") + } + + return formatter.format(time) +} + +@Composable +fun InputSection( + msg: String = "", + onUpdateMessage: (String) -> Unit = {}, + onSendClick: (String) -> Unit = {}, + onPhotoClick: () -> Unit = {} +) { + + Row( + modifier = Modifier + .fillMaxWidth() + .background(Cream) + .padding(6.dp), + verticalAlignment = Alignment.Bottom + ) { + + UlbanBasicTextField( + text = msg, + onTextChange = onUpdateMessage, + modifier = Modifier + .padding(10.dp, 0.dp) + .weight(1f) + .wrapContentHeight(), + maxLines = 3, + textStyle = UlbanTypography.bodyMedium.copy(fontWeight = FontWeight.Normal) + + ) + + Icon( + Icons.AutoMirrored.Outlined.Send, + contentDescription = "", + modifier = Modifier + .size(30.dp) + .clickable { + onSendClick(msg) + } + ) + + } + + +} + +@Composable +@Preview(showBackground = true) +fun ChattingScreenPreview() { + UlbanTheme { + ChattingScreen() + } +} + diff --git a/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/chatting/ChattingViewModel.kt b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/chatting/ChattingViewModel.kt new file mode 100644 index 00000000..59e37c99 --- /dev/null +++ b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/chatting/ChattingViewModel.kt @@ -0,0 +1,182 @@ +package com.sixkids.teacher.board.chatting + +import android.annotation.SuppressLint +import android.util.Log +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn +import androidx.paging.compose.collectAsLazyPagingItems +import com.sixkids.domain.usecase.chatting.GetChattingHistoryUseCase +import com.sixkids.domain.usecase.organization.GetSelectedOrganizationIdUseCase +import com.sixkids.domain.usecase.organization.LoadSelectedOrganizationNameUseCase +import com.sixkids.domain.usecase.user.GetATKUseCase +import com.sixkids.domain.usecase.user.GetUserInfoUseCase +import com.sixkids.domain.usecase.user.LoadUserInfoUseCase +import com.sixkids.model.Chat +import com.sixkids.model.ChatMessage +import com.sixkids.model.UserInfo +import com.sixkids.ui.base.BaseViewModel +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import org.hildan.krossbow.stomp.StompClient +import org.hildan.krossbow.stomp.StompSession +import org.hildan.krossbow.stomp.conversions.convertAndSend +import org.hildan.krossbow.stomp.conversions.moshi.withMoshi +import org.hildan.krossbow.stomp.frame.StompFrame +import org.hildan.krossbow.stomp.headers.StompSendHeaders +import org.hildan.krossbow.stomp.headers.StompSubscribeHeaders +import org.hildan.krossbow.websocket.okhttp.OkHttpWebSocketClient +import javax.inject.Inject +import com.sixkids.teacher.board.BuildConfig + +private const val TAG = "D107" + +@HiltViewModel +class ChattingViewModel @Inject constructor( + private val getATKUseCase: GetATKUseCase, + private val getSelectedOrganizationIdUseCase: GetSelectedOrganizationIdUseCase, + private val loadSelectedOrganizationNameUseCase: LoadSelectedOrganizationNameUseCase, + private val loadUserInfoUseCase: LoadUserInfoUseCase, + private val getChattingHistoryUseCase: GetChattingHistoryUseCase +) : BaseViewModel(ChattingState()) { + + private var roomId = 1L + private lateinit var tkn: String + private lateinit var userInfo: UserInfo + + private lateinit var stompSession: StompSession + private val moshi: Moshi = Moshi.Builder() + .addLast(KotlinJsonAdapterFactory()) + .build() + private lateinit var newChatMessage: Flow + + private lateinit var chattingList: List + + var originalChatList: Flow>? = null + + init { + viewModelScope.launch { + loadSelectedOrganizationNameUseCase().onSuccess { + intent { copy(organizationName = it) } + }.onFailure { + intent { copy(organizationName = "채팅") } + } + } + } + @SuppressLint("CheckResult") + fun initStomp() { + viewModelScope.launch { + try { + loadLocalData() + + originalChatList = + getChattingHistoryUseCase(roomId).cachedIn(viewModelScope) + + + + } catch (e: Exception) { + Log.d(TAG, "initStomp: ${e.message}") + } + } + } + + private fun loadLocalData() { + viewModelScope.launch { + val tknJob = async { getATKUseCase().getOrThrow() } + val roomIdJob = async { getSelectedOrganizationIdUseCase().getOrThrow() } + val userInfoJob = async { loadUserInfoUseCase().getOrThrow() } + + tkn = tknJob.await() + roomId = roomIdJob.await().toLong() + userInfo = userInfoJob.await() + + connectStomp() + + intent { copy(memberId = userInfo.id) } + } + } + + suspend fun connectStomp(){ + viewModelScope.launch { + val okHttpClient = OkHttpClient.Builder() + .addInterceptor( + HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY + } + ) + .build() + + val client = StompClient(OkHttpWebSocketClient(okHttpClient)) + + stompSession = client.connect( + BuildConfig.STOMP_ENDPOINT, + customStompConnectHeaders = mapOf( + HEADER_AUTHORIZATION to tkn, + HEADER_ROOM_ID to roomId.toString() + ) + ).withMoshi(moshi) + + newChatMessage = stompSession.subscribe( + StompSubscribeHeaders( + destination = "$SUBSCRIBE_URL$roomId", + customHeaders = mapOf( + HEADER_AUTHORIZATION to tkn + ) + ) + ) + + + newChatMessage.collect { + val chatMessage = moshi.adapter(Chat::class.java).fromJson(it.bodyAsText) + intent { copy(chatList = chatList + chatMessage!!) } + } + + + } + } + + fun updateMessage(message: String) { + intent { copy(message = message) } + } + + fun sendMessage(message: String) { + viewModelScope.launch { + Log.d(TAG, "sendMessage: ${userInfo.photo}") + stompSession.withMoshi(moshi).convertAndSend( + StompSendHeaders( + destination = SEND_URL, + customHeaders = mapOf( + HEADER_AUTHORIZATION to tkn + ) + ), + ChatMessage(roomId, userInfo.photo, message) + ) + } + intent { copy(message = "") } + } + + fun cancelStomp() { + try { + viewModelScope.launch { + stompSession.disconnect() + } + } catch (e: Exception) { + Log.d(TAG, "cancelStomp: ${e.message}") + } + } + + companion object { + const val HEADER_AUTHORIZATION = "Authorization" + const val HEADER_ROOM_ID = "roomId" + + const val SEND_URL = "/publish/chat/message" + const val SUBSCRIBE_URL = "/subscribe/public/" + } + +} \ No newline at end of file diff --git a/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/main/BoardMainContract.kt b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/main/BoardMainContract.kt new file mode 100644 index 00000000..ba1e938b --- /dev/null +++ b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/main/BoardMainContract.kt @@ -0,0 +1,13 @@ +package com.sixkids.teacher.board.main + +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState + +data class BoardMainState( + val isLoading: Boolean = false, + val classString: String = "", +): UiState + +sealed interface BoardMainEffect : SideEffect{ + data object NavigateToChatting : BoardMainEffect +} \ No newline at end of file diff --git a/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/main/BoardMainScreen.kt b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/main/BoardMainScreen.kt index 123b0efd..6995c081 100644 --- a/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/main/BoardMainScreen.kt +++ b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/main/BoardMainScreen.kt @@ -3,37 +3,120 @@ package com.sixkids.teacher.board.main import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +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.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.sixkids.designsystem.theme.Blue +import com.sixkids.designsystem.theme.BlueText +import com.sixkids.designsystem.theme.Orange +import com.sixkids.designsystem.theme.OrangeText +import com.sixkids.designsystem.theme.Purple +import com.sixkids.designsystem.theme.PurpleText +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.designsystem.theme.component.card.ContentAligment +import com.sixkids.designsystem.theme.component.card.ContentCard +import com.sixkids.teacher.board.R +import com.sixkids.designsystem.R as UlbanRes @Composable fun BoardMainRoute( - padding: PaddingValues + viewModel: BoardMainViewModel = hiltViewModel(), + padding: PaddingValues, + navigateToPost: () -> Unit, + navigateToChatting: () -> Unit, + navigateToAnnounce: () -> Unit, ) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.init() + } + Box( modifier = Modifier .fillMaxSize() .padding(padding) ) { - BoardMainScreen() + BoardMainScreen( + boardMainState = uiState, + postCardOnClick = navigateToPost, + navigateToChatting = navigateToChatting, + announceCardOnClick = navigateToAnnounce + ) } } @Composable fun BoardMainScreen( - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + boardMainState: BoardMainState = BoardMainState(), + postCardOnClick: () -> Unit = {}, + navigateToChatting: () -> Unit = {}, + announceCardOnClick: () -> Unit = {} ) { Column( modifier = modifier .fillMaxSize() .padding(20.dp) ) { - Text(text = "Board Main Screen") + Spacer(modifier = Modifier.height(20.dp)) + Text( + text = stringResource(id = R.string.board_main_title), + style = UlbanTypography.titleLarge, + modifier = Modifier.padding(bottom = 10.dp) + ) + Text( + text = boardMainState.classString.replace("\n", " "), + style = UlbanTypography.titleSmall + ) + + Spacer(modifier = Modifier.weight(1f)) + + ContentCard( + modifier = Modifier.padding(start = 40.dp), + imageModifier = Modifier.rotate(-20f).padding(15.dp), + contentName = stringResource(id = R.string.board_main_announce), + contentImageId = UlbanRes.drawable.announce, + cardColor = Orange, + textColor = OrangeText, + contentAligment = ContentAligment.ImageStart_TextEnd, + onclick = announceCardOnClick + ) + Spacer(modifier = Modifier.height(20.dp)) + ContentCard( + modifier = Modifier.padding(end = 40.dp), + imageModifier = Modifier.padding(15.dp), + contentName = stringResource(id = R.string.board_main_post), + contentImageId = UlbanRes.drawable.board, + cardColor = Blue, + textColor = BlueText, + contentAligment = ContentAligment.ImageEnd_TextStart, + onclick = postCardOnClick + ) + Spacer(modifier = Modifier.height(20.dp)) + ContentCard( + modifier = Modifier.padding(start = 40.dp), + imageModifier = Modifier.padding(10.dp), + contentName = stringResource(id = R.string.board_main_chat), + contentImageId = UlbanRes.drawable.chat, + cardColor = Purple, + textColor = PurpleText, + contentAligment = ContentAligment.ImageStart_TextEnd, + onclick = navigateToChatting + ) + Spacer(modifier = Modifier.weight(1.2f)) } } @@ -41,5 +124,7 @@ fun BoardMainScreen( @Preview(showBackground = true) @Composable fun BoardMainScreenPreview() { - BoardMainScreen() -} \ No newline at end of file + BoardMainScreen( + boardMainState = BoardMainState(classString = "인동초등학교 1학년 1반") + ) +} diff --git a/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/main/BoardMainViewModel.kt b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/main/BoardMainViewModel.kt new file mode 100644 index 00000000..f89f0c0e --- /dev/null +++ b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/main/BoardMainViewModel.kt @@ -0,0 +1,26 @@ +package com.sixkids.teacher.board.main + +import androidx.lifecycle.viewModelScope +import com.sixkids.domain.usecase.organization.LoadSelectedOrganizationNameUseCase +import com.sixkids.teacher.board.chatting.ChattingSideEffect +import com.sixkids.teacher.board.chatting.ChattingState +import com.sixkids.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class BoardMainViewModel @Inject constructor( + private val loadOrganizationNameUseCase: LoadSelectedOrganizationNameUseCase +): BaseViewModel(BoardMainState()){ + fun init(){ + viewModelScope.launch { + loadOrganizationNameUseCase().onSuccess { + intent{ copy(classString = it) } + }.onFailure { + intent { copy(classString = "")} + } + } + + } +} \ No newline at end of file diff --git a/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/navigation/BoardNavigation.kt b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/navigation/BoardNavigation.kt index e4697167..50961d06 100644 --- a/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/navigation/BoardNavigation.kt +++ b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/navigation/BoardNavigation.kt @@ -4,21 +4,151 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions +import androidx.navigation.NavType import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import com.sixkids.teacher.board.announce.announcedetail.AnnounceDetailRoute +import com.sixkids.teacher.board.announce.announcelist.AnnounceListRoute +import com.sixkids.teacher.board.announce.announcewrite.AnnounceWriteRoute +import com.sixkids.teacher.board.chatting.ChattingRoute import com.sixkids.teacher.board.main.BoardMainRoute +import com.sixkids.teacher.board.post.postlist.PostRoute +import com.sixkids.teacher.board.post.postdetail.PostDetailRoute +import com.sixkids.teacher.board.post.postwrite.PostWriteRoute +import com.sixkids.ui.SnackbarToken fun NavController.navigateBoard(navOptions: NavOptions) { navigate(BoardRoute.defaultRoute, navOptions) } +fun NavController.navigatePost() { + navigate(BoardRoute.postRoute) +} + +fun NavController.navigatePostWrite() { + navigate(BoardRoute.postWriteRoute) +} + +fun NavController.navigatePostDetail(postId: Long) { + navigate(BoardRoute.postDetailRoute(postId)) +} + +fun NavController.navigateChatting() { + navigate(BoardRoute.chattingRoute) +} + +fun NavController.navigateAnnounce() { + navigate(BoardRoute.announceRoute) +} + +fun NavController.navigateAnnounceWrite() { + navigate(BoardRoute.announceWriteRoute) +} + +fun NavController.navigateAnnounceDetail(announceDetailId: Long) { + navigate(BoardRoute.announceDetailRoute(announceDetailId)) +} + fun NavGraphBuilder.boardNavGraph( padding: PaddingValues, + navigateToPost: () -> Unit, + navigateToPostDetail: (Long) -> Unit, + navigateToPostWrite: () -> Unit, + onBackClick: () -> Unit, + onShowSnackBar: (SnackbarToken) -> Unit, + navigateToChatting: () -> Unit, + navigateToAnnounceDetail: (Long) -> Unit, + navigateToAnnounceWrite: () -> Unit, + navigateToAnnounceList: () -> Unit, ) { - composable(route = BoardRoute.defaultRoute){ - BoardMainRoute(padding) + composable(route = BoardRoute.defaultRoute) { + BoardMainRoute( + padding = padding, + navigateToPost = navigateToPost, + navigateToChatting = navigateToChatting, + navigateToAnnounce = navigateToAnnounceList + ) + } + + composable( + route = BoardRoute.postRoute, + ) { + PostRoute( + padding = padding, + navigateToDetail = navigateToPostDetail, + navigateToWrite = navigateToPostWrite, + onShowSnackBar = onShowSnackBar + ) + } + + composable(BoardRoute.postWriteRoute) { + PostWriteRoute( + padding = padding, + navigateBack = onBackClick, + onShowSnackBar = onShowSnackBar + ) + } + + composable( + route = BoardRoute.postDetailRoute, + arguments = listOf(navArgument(BoardRoute.postDetailARG) { type = NavType.LongType }) + ) { + PostDetailRoute( + padding = padding, + onShowSnackBar = onShowSnackBar + ) + } + + composable(route = BoardRoute.chattingRoute) { + ChattingRoute( + onBackClick = onBackClick, + onShowSnackBar = onShowSnackBar + ) + } + + composable( + route = BoardRoute.announceRoute, + ) { + AnnounceListRoute( + padding = padding, + navigateToAnnounceDetail = navigateToAnnounceDetail, + navigateToAnnounceWrite = navigateToAnnounceWrite, + onShowSnackBar = onShowSnackBar + ) + } + + composable(BoardRoute.announceWriteRoute) { + AnnounceWriteRoute( + padding = padding, + navigateBack = onBackClick, + onShowSnackBar = onShowSnackBar + ) + } + + composable( + route = BoardRoute.announceDetailRoute, + arguments = listOf(navArgument(BoardRoute.announceDetailARG) { type = NavType.LongType }) + ) { + AnnounceDetailRoute( + padding = padding, + onShowSnackBar = onShowSnackBar + ) } } object BoardRoute { + const val postDetailARG = "postId" + const val announceDetailARG = "announceDetailId" + const val defaultRoute = "board" -} \ No newline at end of file + const val postRoute = "post" + const val postWriteRoute = "post_write" + const val postDetailRoute = "post_detail/{$postDetailARG}" + const val chattingRoute = "chatting" + const val announceRoute = "announce" + const val announceWriteRoute = "announce_write" + const val announceDetailRoute = "announce_detail/{$announceDetailARG}" + + fun postDetailRoute(postId: Long) = "post_detail/$postId" + fun announceDetailRoute(announceDetailId: Long) = "announce_detail/$announceDetailId" +} diff --git a/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/post/postdetail/PostDetailContract.kt b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/post/postdetail/PostDetailContract.kt new file mode 100644 index 00000000..562a8b92 --- /dev/null +++ b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/post/postdetail/PostDetailContract.kt @@ -0,0 +1,17 @@ +package com.sixkids.teacher.board.post.postdetail + +import com.sixkids.model.PostDetail +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState + +sealed interface PostDetailEffect: SideEffect{ + data object RefreshPostDetail: PostDetailEffect + data class OnShowSnackbar(val message: String) : PostDetailEffect +} + +data class PostDetailState( + val isLoading: Boolean = false, + val postDetail: PostDetail = PostDetail(), + val commentText: String = "", + val selectedCommentId: Long? = null, +) : UiState \ No newline at end of file diff --git a/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/post/postdetail/PostDetailScreen.kt b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/post/postdetail/PostDetailScreen.kt new file mode 100644 index 00000000..2400dae4 --- /dev/null +++ b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/post/postdetail/PostDetailScreen.kt @@ -0,0 +1,245 @@ +package com.sixkids.teacher.board.post.postdetail + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import coil.compose.AsyncImage +import com.sixkids.designsystem.component.screen.LoadingScreen +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.model.Comment +import com.sixkids.model.MemberSimple +import com.sixkids.model.PostDetail +import com.sixkids.model.Recomment +import com.sixkids.teacher.board.post.postdetail.component.CommentItem +import com.sixkids.teacher.board.post.postdetail.component.CommentTextField +import com.sixkids.teacher.board.post.postdetail.component.PostWriterInfo +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.util.formatToMonthDayTime +import java.time.LocalDateTime +import com.sixkids.designsystem.R as UlbanRes + +@Composable +fun PostDetailRoute( + viewModel: PostDetailViewModel = hiltViewModel(), + padding: PaddingValues, + onShowSnackBar: (SnackbarToken) -> Unit +) { + val uiState by viewModel.uiState.collectAsState() + + LaunchedEffect(Unit) { + viewModel.getPostDetail() + } + + LaunchedEffect(viewModel.sideEffect) { + viewModel.sideEffect.collect { sideEffect -> + when (sideEffect) { + PostDetailEffect.RefreshPostDetail -> viewModel.getPostDetail() + is PostDetailEffect.OnShowSnackbar -> { + onShowSnackBar(SnackbarToken(message = sideEffect.message)) + } + } + } + } + + Box(modifier = Modifier.padding(padding)) { + PostDetailScreen( + postDetailState = uiState, + onCommentTextChanged = viewModel::onCommentTextChanged, + onClickComment = viewModel::onSelectedCommentId, + onClickSubmitComment = viewModel::onNewComment, + ) + } +} + +@Composable +fun PostDetailScreen( + modifier: Modifier = Modifier, + postDetailState: PostDetailState, + onCommentTextChanged: (String) -> Unit = {}, + onClickComment: (Long) -> Unit = {}, + onClickSubmitComment: () -> Unit = {}, + postDeleteOnclick: () -> Unit = {} +) { + + val scrollState = rememberScrollState() + + BackHandler( + enabled = postDetailState.selectedCommentId != null, + onBack = {onClickComment(postDetailState.selectedCommentId?: 0)} + ) + + Box{ + Column { + Column( + modifier = modifier + .weight(1f) + .padding(20.dp) + .verticalScroll(scrollState), + ) { + // 작성자 정보 + Row( + verticalAlignment = Alignment.CenterVertically + ) { + PostWriterInfo( + height = 60.dp, + writer = postDetailState.postDetail.writeMember.name, + dateString = postDetailState.postDetail.createTime.formatToMonthDayTime(), + writerImageUrl = postDetailState.postDetail.writeMember.photo + ) + Spacer(modifier = Modifier.weight(1f)) + Icon( + modifier = Modifier + .size(30.dp) + .clickable { postDeleteOnclick() }, + imageVector = ImageVector.vectorResource(id = UlbanRes.drawable.ic_delete), + contentDescription = "더보기" + ) + } + + Spacer(modifier = Modifier.height(10.dp)) + Text( + text = postDetailState.postDetail.title, + style = UlbanTypography.titleLarge + ) + Spacer(modifier = Modifier.height(10.dp)) + // 이미지 + if (postDetailState.postDetail.imageUri.isNotEmpty()) { + AsyncImage( + model = postDetailState.postDetail.imageUri, + contentDescription = null, + contentScale = ContentScale.Crop, + ) + } + Spacer(modifier = Modifier.height(10.dp)) + Text( + text = postDetailState.postDetail.content, + style = UlbanTypography.bodyLarge + ) + Spacer(modifier = Modifier.height(10.dp)) + HorizontalDivider( + thickness = 2.dp, + color = Color.Black + ) + // 댓글 목록 + for (comment in postDetailState.postDetail.comments) { + CommentItem( + selected = postDetailState.selectedCommentId == comment.id, + writer = comment.member.name, + dateString = comment.createTime.formatToMonthDayTime(), + writerImageUrl = comment.member.photo, + commentString = comment.content, + recommentOnclick = { + onClickComment(comment.id) + } + ) + // 대댓글 목록 + for (recomment in comment.recomments) { + Row { + Icon( + modifier = Modifier.padding(4.dp), + imageVector = ImageVector.vectorResource(id = UlbanRes.drawable.ic_recomment), + contentDescription = null + ) + CommentItem( + writer = recomment.member.name, + dateString = recomment.createTime.formatToMonthDayTime(), + writerImageUrl = recomment.member.photo, + commentString = recomment.content, + isRecomment = true + ) + } + + } + } + } + CommentTextField( + msg = postDetailState.commentText, + onTextIuputChange = onCommentTextChanged, + onSendClick = { onClickSubmitComment() } + ) + } + + + if (postDetailState.isLoading) { + LoadingScreen() + } + } + +} + +@Preview(showBackground = true) +@Composable +fun PostDetailScreenPreview() { + PostDetailScreen( + postDetailState = postDetailStateDummy + ) +} + + +val recommentDummy = Recomment( + 1, + member = MemberSimple( + id = 1, + name = "작성자", + photo = "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTKe3bkhl96AgtmHyTiKW-KXRst2-5MoY6xB9-mZP74BQ&s" + ), + content = "내용내용내용내용내용내용내용내용내용내용내용내용내용내용내용", + createTime = LocalDateTime.now(), + updateTime = LocalDateTime.now(), + 1, +) + +val commentDummy = Comment( + 1, + member = MemberSimple( + id = 1, + name = "작성자", + photo = "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTKe3bkhl96AgtmHyTiKW-KXRst2-5MoY6xB9-mZP74BQ&s" + ), + "내용내용내용 내용내용내용내용내용내용내용내용내용 내용내용내용내용내용내용내용내용내용", + LocalDateTime.now(), + LocalDateTime.now(), + listOf(recommentDummy, recommentDummy) +) + +val postDetailStateDummy = PostDetailState( + isLoading = false, + postDetail = PostDetail( + title = "제목", + content = "내용내용내용내용내용내용내용내용내용내용내용내용내용", + writeMember = MemberSimple( + id = 1, + name = "작성자", + photo = "https://picsum.photos/200/300" + ), + createTime = LocalDateTime.now(), + imageUri = "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTKe3bkhl96AgtmHyTiKW-KXRst2-5MoY6xB9-mZP74BQ&s", + comments = listOf(commentDummy, commentDummy) + ) +) \ No newline at end of file diff --git a/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/post/postdetail/PostDetailViewModel.kt b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/post/postdetail/PostDetailViewModel.kt new file mode 100644 index 00000000..0e4a448e --- /dev/null +++ b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/post/postdetail/PostDetailViewModel.kt @@ -0,0 +1,103 @@ +package com.sixkids.teacher.board.post.postdetail + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.sixkids.domain.usecase.comment.DeleteCommentUseCase +import com.sixkids.domain.usecase.comment.NewCommentUseCase +import com.sixkids.domain.usecase.comment.NewRecommentUseCase +import com.sixkids.domain.usecase.comment.ReportCommentUseCase +import com.sixkids.domain.usecase.comment.UpdateCommentUsecase +import com.sixkids.domain.usecase.post.DeletePostUseCase +import com.sixkids.domain.usecase.post.GetPostDetailUseCase +import com.sixkids.domain.usecase.post.UpdatePostUseCase +import com.sixkids.teacher.board.navigation.BoardRoute +import com.sixkids.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class PostDetailViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val getPostDetailUseCase: GetPostDetailUseCase, + private val updatePostUseCase: UpdatePostUseCase, + private val deletePostUsecase: DeletePostUseCase, + private val deleteCommentUseCase: DeleteCommentUseCase, + private val updateCommentUsecase: UpdateCommentUsecase, + private val newCommentUseCase: NewCommentUseCase, + private val newRecommentUseCase: NewRecommentUseCase, + private val reportCommentUseCase: ReportCommentUseCase +) : BaseViewModel(PostDetailState()) { + + private val postId: Long = savedStateHandle.get(BoardRoute.postDetailARG)!! + + fun onCommentTextChanged(commentText: String) = intent { copy(commentText = commentText) } + fun onSelectedCommentId(commentId: Long?) = intent { + if (currentState.selectedCommentId == commentId) { + copy(selectedCommentId = null) + } else { + copy(selectedCommentId = commentId) + } + } + + fun getPostDetail() { + viewModelScope.launch { + intent { copy(isLoading = true) } + getPostDetailUseCase(postId).onSuccess { + intent { copy(postDetail = it) } + }.onFailure { + postSideEffect(PostDetailEffect.OnShowSnackbar(it.message ?: "게시글을 불러오지 못했어요")) + } + intent { copy(isLoading = false) } + } + } + + fun onNewComment() { + if (currentState.commentText.isBlank()) { + postSideEffect(PostDetailEffect.OnShowSnackbar("댓글을 입력해주세요")) + } else { + if (currentState.selectedCommentId == null) { + viewModelScope.launch { + intent { copy(isLoading = true) } + newCommentUseCase( + postId = postId, + content = currentState.commentText, + ).onSuccess { + postSideEffect(PostDetailEffect.OnShowSnackbar("댓글이 작성되었습니다")) + intent { copy(commentText = "", selectedCommentId = null) } + getPostDetail() + }.onFailure { + postSideEffect( + PostDetailEffect.OnShowSnackbar( + it.message ?: "댓글 작성에 실패했어요" + ) + ) + } + intent { copy(isLoading = false) } + } + } else { + viewModelScope.launch { + intent { copy(isLoading = true) } + newRecommentUseCase( + postId = postId, + content = currentState.commentText, + currentState.selectedCommentId!! + ).onSuccess { + postSideEffect(PostDetailEffect.OnShowSnackbar("댓글이 작성되었습니다")) + intent { copy(commentText = "", selectedCommentId = null)} + getPostDetail() + }.onFailure { + postSideEffect( + PostDetailEffect.OnShowSnackbar( + it.message ?: "댓글 작성에 실패했어요" + ) + ) + } + intent { copy(isLoading = false) } + } + } + } + } + + +} \ No newline at end of file diff --git a/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/post/postdetail/component/CommentItem.kt b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/post/postdetail/component/CommentItem.kt new file mode 100644 index 00000000..74c9b70c --- /dev/null +++ b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/post/postdetail/component/CommentItem.kt @@ -0,0 +1,133 @@ +package com.sixkids.teacher.board.post.postdetail.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Card +import androidx.compose.material3.CardColors +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.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.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.sixkids.designsystem.theme.Blue +import com.sixkids.designsystem.theme.Gray +import com.sixkids.designsystem.theme.GrayLight +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.designsystem.R as UlbanRes + +@Composable +fun CommentItem( + modifier: Modifier = Modifier, + selected: Boolean = false, + writer: String = "", + dateString: String = "00/00 00:00", + writerImageUrl: String = "", + commentString: String = "", + isRecomment: Boolean = false, + recommentOnclick: () -> Unit = {}, + deleteOnclick: (() -> Unit)? = null +){ + Card( + colors = CardDefaults.cardColors( + containerColor = + if (selected) { Blue} + else if (isRecomment) {GrayLight} + else {Color.Transparent} + ), + ) { + Column( + modifier = modifier + .padding(start = 10.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + AsyncImage( + modifier = Modifier + .height(36.dp) + .aspectRatio(1f), + model = writerImageUrl, + contentScale = ContentScale.Crop, + contentDescription = "작성자 프로필 사진" + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = writer, + style = UlbanTypography.bodyMedium + ) + Spacer(modifier = Modifier.weight(1f)) + if (!isRecomment) { + Icon( + modifier = Modifier.clickable{recommentOnclick()}, + imageVector = ImageVector.vectorResource(id = UlbanRes.drawable.ic_chat_bubble_outline), + contentDescription = null + ) + } + if (deleteOnclick != null) { + Icon( + modifier = Modifier.clickable{deleteOnclick()}, + imageVector = ImageVector.vectorResource(id = UlbanRes.drawable.ic_delete), + contentDescription = null + ) + } + } + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = commentString, + style = UlbanTypography.bodyMedium + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = dateString, + style = UlbanTypography.bodySmall.copy( + color = Gray + ) + ) + Spacer(modifier = Modifier.height(4.dp)) + } + } +} + +@Preview(showBackground = true) +@Composable +fun CommentItemPreview() { + Column { + CommentItem( + writer = "오하빈", + dateString = "09/01 12:00", + writerImageUrl = "https://www.google.com/images/branding/googlelogo/2x/googlelogo_light_color_272x92dp.png", + commentString = "댓글 내용", + deleteOnclick = {}, + selected = true + ) + CommentItem( + writer = "오하빈", + dateString = "09/01 12:00", + commentString = "댓글 내용", + writerImageUrl = "https://www.google.com/images/branding/googlelogo/2x/googlelogo_light_color_272x92dp.png", + ) + CommentItem( + writer = "오하빈", + dateString = "09/01 12:00", + commentString = "댓글 내용", + writerImageUrl = "https://www.google.com/images/branding/googlelogo/2x/googlelogo_light_color_272x92dp.png", + isRecomment = true, + deleteOnclick = {} + ) + } + +} \ No newline at end of file diff --git a/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/post/postdetail/component/CommentTextField.kt b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/post/postdetail/component/CommentTextField.kt new file mode 100644 index 00000000..16fd9449 --- /dev/null +++ b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/post/postdetail/component/CommentTextField.kt @@ -0,0 +1,79 @@ +package com.sixkids.teacher.board.post.postdetail.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.Send +import androidx.compose.material3.Card +import androidx.compose.material3.CardColors +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +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.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.sixkids.designsystem.component.textfield.UlbanBasicTextField +import com.sixkids.designsystem.theme.Gray +import com.sixkids.designsystem.theme.GrayLight +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.teacher.board.R + +@Composable +fun CommentTextField( + msg: String = "", + onTextIuputChange: (String) -> Unit = {}, + onSendClick: (String) -> Unit = {}, +) { + Card( + modifier = Modifier + .padding(6.dp), + colors = CardDefaults.cardColors( + containerColor = GrayLight + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + UlbanBasicTextField( + text = msg, + onTextChange = onTextIuputChange, + modifier = Modifier + .padding(10.dp, 0.dp) + .weight(1f) + .wrapContentHeight(), + maxLines = 3, + textStyle = UlbanTypography.bodyMedium.copy(fontWeight = FontWeight.Normal), + hint = stringResource(id = R.string.board_detail_comment_hint) + ) + + Icon( + Icons.AutoMirrored.Outlined.Send, + contentDescription = "", + modifier = Modifier + .size(30.dp) + .padding(end = 4.dp) + .clickable { + onSendClick(msg) + } + ) + + } + } + +} + +@Preview(showBackground = true) +@Composable +fun CommentTextFieldPreview() { + CommentTextField() +} \ No newline at end of file diff --git a/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/post/postdetail/component/PostWriterInfo.kt b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/post/postdetail/component/PostWriterInfo.kt new file mode 100644 index 00000000..5546a0e0 --- /dev/null +++ b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/post/postdetail/component/PostWriterInfo.kt @@ -0,0 +1,64 @@ +package com.sixkids.teacher.board.post.postdetail.component + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.sixkids.designsystem.theme.UlbanTypography + +@Composable +fun PostWriterInfo( + height: Dp = 60.dp, + writer: String = "", + dateString: String = "00/00 00:00", + writerImageUrl: String = "" +) { + Row( + modifier = Modifier.height(height), + verticalAlignment = Alignment.CenterVertically + ) { + AsyncImage( + modifier = Modifier + .fillMaxHeight() + .aspectRatio(1f), + model = writerImageUrl, + contentScale = ContentScale.Crop, + contentDescription = "프로필 사진" + ) + Spacer(modifier = Modifier.width(8.dp)) + Column { + Text( + text = writer, + style = UlbanTypography.bodyLarge + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = dateString, + style = UlbanTypography.bodyMedium + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun PostWriterInfoPreview() { + PostWriterInfo( + height = 60.dp, + writer = "홍유준 선생님", + dateString = "10/10 10:10", + writerImageUrl = "" + ) +} \ No newline at end of file diff --git a/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/post/postlist/PostContract.kt b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/post/postlist/PostContract.kt new file mode 100644 index 00000000..9bad00b5 --- /dev/null +++ b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/post/postlist/PostContract.kt @@ -0,0 +1,16 @@ +package com.sixkids.teacher.board.post.postlist + +import com.sixkids.model.Post +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState + +sealed interface PostEffect : SideEffect { + data object NavigateToPostDetail: PostEffect + data object NavigateToWritePost: PostEffect + data class OnShowSnackBar(val message : String) : PostEffect +} + +data class PostState( + val isLoding: Boolean = false, + val classString: String = "", +): UiState \ No newline at end of file diff --git a/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/post/postlist/PostScreen.kt b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/post/postlist/PostScreen.kt new file mode 100644 index 00000000..0d24d75a --- /dev/null +++ b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/post/postlist/PostScreen.kt @@ -0,0 +1,158 @@ +package com.sixkids.teacher.board.post.postlist + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import com.sixkids.designsystem.component.appbar.UlbanDefaultAppBar +import com.sixkids.designsystem.component.appbar.UlbanDetailAppBar +import com.sixkids.designsystem.component.button.EditFAB +import com.sixkids.designsystem.component.screen.LoadingScreen +import com.sixkids.designsystem.theme.Blue +import com.sixkids.designsystem.theme.BlueDark +import com.sixkids.designsystem.theme.Orange +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.model.Post +import com.sixkids.teacher.board.R +import com.sixkids.teacher.board.post.postlist.component.PostItem +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.util.formatToMonthDayTimeKorean +import com.sixkids.designsystem.R as UlbanRes + +@Composable +fun PostRoute( + viewModel: PostViewModel = hiltViewModel(), + navigateToDetail: (postId:Long) -> Unit, + navigateToWrite: () -> Unit, + onShowSnackBar: (SnackbarToken) -> Unit, + padding: PaddingValues +) { + + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.getPostList() + } + + LaunchedEffect(key1 = viewModel.sideEffect) { + viewModel.sideEffect.collect { sideEffect -> + when (sideEffect) { + PostEffect.NavigateToPostDetail -> {} + PostEffect.NavigateToWritePost -> {} + is PostEffect.OnShowSnackBar -> { + onShowSnackBar(SnackbarToken(message = sideEffect.message)) + } + } + } + } + + Box( + modifier = Modifier + .padding(padding) + .fillMaxSize() + ) { + PostScreen( + postState = uiState, + postItems = viewModel.postList?.collectAsLazyPagingItems(), + postItemOnclick = navigateToDetail, + fabClick = navigateToWrite + ) + } +} + + +@Composable +fun PostScreen( + modifier: Modifier = Modifier, + postState: PostState = PostState(), + postItems: LazyPagingItems? = null, + postItemOnclick: (postId: Long) -> Unit = {}, + fabClick: () -> Unit = {} +) { + val listState = rememberLazyListState() + + Box( + modifier = modifier + .fillMaxSize() + ) { + Column( + + ) { + UlbanDefaultAppBar( + leftIcon = UlbanRes.drawable.board, + title = stringResource(id = R.string.board_main_post), + content = stringResource(id = R.string.board_main_post), + body = postState.classString.replace("\n", " "), + color = Blue + ) + + if (postItems == null){ + Spacer(modifier = Modifier.weight(1f)) + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.board_post_no_items), + textAlign = TextAlign.Center, + style = UlbanTypography.bodyLarge, + ) + Spacer(modifier = Modifier.weight(1f)) + } else { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + state = listState, + ) { + items(postItems.itemCount) { index -> + postItems[index]?.let { post -> + PostItem( + title = post.title, + writer = post.writer, + dateString = post.time.formatToMonthDayTimeKorean(), + commentCount = post.commentCount, + onClick = { postItemOnclick(post.id) } + ) + } + } + } + } + } + //FAB + EditFAB( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(16.dp), + buttonColor = Blue, + iconColor = BlueDark, + onClick = fabClick + ) + if (postState.isLoding){ + LoadingScreen() + } + } +} + + +@Preview(showBackground = true) +@Composable +fun PostRoutePreview() { + PostScreen() +} \ No newline at end of file diff --git a/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/post/postlist/PostViewModel.kt b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/post/postlist/PostViewModel.kt new file mode 100644 index 00000000..c929e45c --- /dev/null +++ b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/post/postlist/PostViewModel.kt @@ -0,0 +1,53 @@ +package com.sixkids.teacher.board.post.postlist + +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn +import com.sixkids.domain.usecase.organization.GetSelectedOrganizationIdUseCase +import com.sixkids.domain.usecase.organization.LoadSelectedOrganizationNameUseCase +import com.sixkids.domain.usecase.post.GetPostListUseCase +import com.sixkids.model.Post +import com.sixkids.model.PostCategory +import com.sixkids.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class PostViewModel @Inject constructor( + private val getPostListUseCase: GetPostListUseCase, + private val getSelectedOrganizationIdUseCase: GetSelectedOrganizationIdUseCase, + private val loadSelectedOrganizationNameUseCase: LoadSelectedOrganizationNameUseCase +): BaseViewModel(PostState()){ + + private var organizationId: Int? = null + + var postList: Flow>? = null + + fun getPostList() { + viewModelScope.launch { + intent { copy(isLoding = true) } + loadSelectedOrganizationNameUseCase().onSuccess { + intent { copy(classString = it) } + }.onFailure { + intent { copy(classString = "") } + } + + if (organizationId == null){ + organizationId = getSelectedOrganizationIdUseCase().getOrNull() + } + + if (organizationId != null){ + postList = getPostListUseCase( + organizationId = organizationId!!, + postCategory = PostCategory.FREE + ).cachedIn(viewModelScope) + } else { + postSideEffect(PostEffect.OnShowSnackBar("학급 정보를 불러오지 못했어요 ;(")) + } + + intent { copy(isLoding = false) } + } + } +} \ No newline at end of file diff --git a/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/post/postlist/component/CommentCount.kt b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/post/postlist/component/CommentCount.kt new file mode 100644 index 00000000..ebb33b5b --- /dev/null +++ b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/post/postlist/component/CommentCount.kt @@ -0,0 +1,38 @@ +package com.sixkids.teacher.board.post.postlist.component + +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import com.sixkids.designsystem.theme.OrangeDark +import com.sixkids.designsystem.theme.OrangeText +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.designsystem.R as UlbanRes + +@Composable +fun CommentCount( + count: Int +) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = ImageVector.vectorResource(id = UlbanRes.drawable.ic_chat_bubble), + contentDescription = null, + tint = OrangeText + ) + Text( + text = count.toString(), + style = UlbanTypography.bodyMedium + ) + } +} + + +@Preview(showBackground = true) +@Composable +fun CommentCountPreview() { + CommentCount(10) +} \ No newline at end of file diff --git a/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/post/postlist/component/PostItem.kt b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/post/postlist/component/PostItem.kt new file mode 100644 index 00000000..1ef08cc3 --- /dev/null +++ b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/post/postlist/component/PostItem.kt @@ -0,0 +1,80 @@ +package com.sixkids.teacher.board.post.postlist.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.layout.width +import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.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 com.sixkids.designsystem.theme.UlbanTypography + +@Composable +fun PostItem( + modifier: Modifier = Modifier , + title: String, + writer: String, + commentCount: Int, + dateString: String, + dividerColor: Color = Color.Black, + onClick: () -> Unit = {} +) { + Column( + modifier = modifier.padding(bottom = 8.dp).clickable { onClick() } + ) { + Text( + text = title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = UlbanTypography.titleMedium + ) + Spacer(modifier = Modifier.height(10.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + if (commentCount > 0){ + CommentCount(count = commentCount) + } + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = writer, + style = UlbanTypography.bodyMedium + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + text = dateString, + style = UlbanTypography.bodyMedium + ) + } + HorizontalDivider( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + thickness = 2.dp, + color = dividerColor + ) + } +} + +@Preview(showBackground = true) +@Composable +fun PostItemPreview() { + PostItem( + title = "이따 마크 할 사람~~!", + writer = "오하빈", + commentCount = 3, + dateString = "2024.04.16 14:30" + ) +} \ No newline at end of file diff --git a/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/post/postwrite/PostWriteContract.kt b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/post/postwrite/PostWriteContract.kt new file mode 100644 index 00000000..8db26a1e --- /dev/null +++ b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/post/postwrite/PostWriteContract.kt @@ -0,0 +1,18 @@ +package com.sixkids.teacher.board.post.postwrite + +import android.graphics.Bitmap +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState + +sealed interface PostWriteEffect: SideEffect{ + data object NavigateBack : PostWriteEffect + data class OnShowSnackbar(val message: String) : PostWriteEffect +} + +data class PostWriteState( + val isLoading: Boolean = false, + val title: String = "", + val content: String = "", + val anonymousChecked: Boolean = false, + val photo: Bitmap? = null +): UiState \ No newline at end of file diff --git a/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/post/postwrite/PostWriteScreen.kt b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/post/postwrite/PostWriteScreen.kt new file mode 100644 index 00000000..8fae0f74 --- /dev/null +++ b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/post/postwrite/PostWriteScreen.kt @@ -0,0 +1,290 @@ +package com.sixkids.teacher.board.post.postwrite + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.ImageDecoder +import android.os.Build +import android.provider.MediaStore +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.ScrollableState +import androidx.compose.foundation.gestures.scrollable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +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.layout.wrapContentHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.sixkids.designsystem.component.button.UlbanFilledButton +import com.sixkids.designsystem.component.screen.LoadingScreen +import com.sixkids.designsystem.theme.Blue +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.teacher.board.R +import com.sixkids.teacher.board.post.postwrite.component.PageTitle +import com.sixkids.ui.SnackbarToken +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import com.sixkids.designsystem.R as UlbanRes + +@Composable +fun PostWriteRoute( + viewModel: PostWriteViewModel = hiltViewModel(), + padding: PaddingValues, + navigateBack: () -> Unit, + onShowSnackBar: (SnackbarToken) -> Unit +) { + + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + val context = LocalContext.current + val photoLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickVisualMedia() + ) { uri -> + uri?.let { + try { + val bitmap = if (Build.VERSION.SDK_INT < 28) { + MediaStore.Images.Media.getBitmap(context.contentResolver, it) + } else { + ImageDecoder.decodeBitmap( + ImageDecoder.createSource( + context.contentResolver, + it + ) + ) + } + viewModel.onAddPhoto(bitmap) + } catch (e: IOException) { + viewModel.showToast("사진 호출에 실패했습니다.") + } + } + } + + LaunchedEffect(key1 = viewModel.sideEffect) { + viewModel.sideEffect.collect { sideEffect -> + when (sideEffect) { + PostWriteEffect.NavigateBack -> navigateBack() + is PostWriteEffect.OnShowSnackbar -> { + onShowSnackBar(SnackbarToken(message = sideEffect.message)) + } + } + } + } + + + + PostWriteScreen( + postWriteState = uiState, + cancelOnClick = { viewModel.onBack() }, + submitOnClick = { + viewModel.onPost( + uiState.photo?.let { saveBitmapToFile(context, it, "post_photo.jpg") } + ) + }, + titleValueChange = { viewModel.onTitleChanged(it) }, + contentValueChange = { viewModel.onContentChanged(it) }, + anonymousCheckedChange = { viewModel.onAnonymousChecked(it) }, + addPhotoOnClick = { photoLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) } + ) + +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PostWriteScreen( + modifier: Modifier = Modifier, + postWriteState: PostWriteState = PostWriteState(), + cancelOnClick: () -> Unit = {}, + submitOnClick: () -> Unit = {}, + titleValueChange: (String) -> Unit = {}, + contentValueChange: (String) -> Unit = {}, + anonymousCheckedChange: (Boolean) -> Unit = {}, + addPhotoOnClick: () -> Unit = {} +) { + + val scrollState = rememberScrollState() + + LaunchedEffect(postWriteState.content) { + scrollState.scrollTo(scrollState.maxValue) + } + + Box { + Column( + modifier = modifier + .fillMaxSize() + .padding(20.dp) + ) { + Spacer(modifier = Modifier.height(20.dp)) + PageTitle( + title = stringResource(id = R.string.board_write_title), + cancelOnclick = cancelOnClick + ) + //title + OutlinedTextField( + value = postWriteState.title, + onValueChange = titleValueChange, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent + ), + placeholder = { + Text( + text = stringResource(id = R.string.board_write_content_title), + style = UlbanTypography.bodyLarge + ) + }, + textStyle = UlbanTypography.bodyLarge + ) + HorizontalDivider( + thickness = 2.dp, + color = Color.Black + ) + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .verticalScroll(scrollState) + ) { + //photo + if (postWriteState.photo != null) { + Spacer(modifier = Modifier.height(10.dp)) + Image( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f), + bitmap = postWriteState.photo.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Crop + ) + } + //content + OutlinedTextField( + value = postWriteState.content, + onValueChange = { string -> + contentValueChange(string) + }, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent + ), + placeholder = { + Text( + text = stringResource(id = R.string.board_write_content_content), + style = UlbanTypography.bodyLarge + ) + }, + textStyle = UlbanTypography.bodyLarge + ) + } + Row( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(), + verticalAlignment = Alignment.CenterVertically + ) { + // 이미지 추가 아이콘 + Icon( + modifier = Modifier + .size(40.dp) + .clickable(onClick = addPhotoOnClick), + imageVector = ImageVector.vectorResource(id = UlbanRes.drawable.ic_photo_camera), + contentDescription = null + ) + Spacer(modifier = Modifier.width(10.dp)) + // 익명 체크박스 + Checkbox( + modifier = Modifier.scale(1.2f), + checked = postWriteState.anonymousChecked, + onCheckedChange = anonymousCheckedChange, + colors = CheckboxDefaults.colors( + checkedColor = Blue, + uncheckedColor = Color.Black + ) + ) + Text( + text = stringResource(id = R.string.board_write_anonymous), + style = UlbanTypography.bodyLarge + ) + Spacer(modifier = Modifier.weight(1f)) + // 등록 버튼 + UlbanFilledButton( + text = stringResource(id = R.string.board_write_submit), + onClick = submitOnClick + ) + } + } + + if (postWriteState.isLoading) { + LoadingScreen() + } + } + +} + +fun saveBitmapToFile(context: Context, bitmap: Bitmap?, fileName: String): File? { + val directory = context.getExternalFilesDir(null) ?: return null + + val file = File(directory, fileName) + var fileOutputStream: FileOutputStream? = null + + try { + fileOutputStream = FileOutputStream(file) + bitmap?.compress(Bitmap.CompressFormat.JPEG, 100, fileOutputStream) + fileOutputStream.flush() + } catch (e: Exception) { + e.printStackTrace() + return null + } finally { + try { + fileOutputStream?.close() + } catch (e: Exception) { + e.printStackTrace() + } + } + + return file +} + +@Preview(showBackground = true) +@Composable +fun PostWriteScreenPreview() { + PostWriteScreen() +} \ No newline at end of file diff --git a/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/post/postwrite/PostWriteViewModel.kt b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/post/postwrite/PostWriteViewModel.kt new file mode 100644 index 00000000..b75c1fbe --- /dev/null +++ b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/post/postwrite/PostWriteViewModel.kt @@ -0,0 +1,57 @@ +package com.sixkids.teacher.board.post.postwrite + +import android.graphics.Bitmap +import androidx.lifecycle.viewModelScope +import com.sixkids.domain.usecase.organization.GetSelectedOrganizationIdUseCase +import com.sixkids.domain.usecase.post.NewPostUseCase +import com.sixkids.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import java.io.File +import javax.inject.Inject + +@HiltViewModel +class PostWriteViewModel @Inject constructor( + private val newPostUseCase: NewPostUseCase, + private val getSelectedOrganizationIdUseCase: GetSelectedOrganizationIdUseCase +): BaseViewModel(PostWriteState()){ + + private var organizationId: Int? = null + + fun onBack() = postSideEffect(PostWriteEffect.NavigateBack) + fun onTitleChanged(title: String) = intent { copy(title = title) } + fun onContentChanged(content: String) = intent { copy(content = content) } + fun onAnonymousChecked(checked: Boolean) = intent { copy(anonymousChecked = checked) } + fun onAddPhoto(bitmap: Bitmap) = intent { copy(photo = bitmap) } + fun showToast(message: String) = postSideEffect(PostWriteEffect.OnShowSnackbar(message)) + + fun onPost(photo: File?) { + viewModelScope.launch { + intent { copy(isLoading = true) } + + if (organizationId == null) { + organizationId = getSelectedOrganizationIdUseCase().getOrNull() + } + + if (organizationId != null) { + newPostUseCase( + organizationId = organizationId!!.toLong(), + title = currentState.title, + content = currentState.content, + secretStatus = currentState.anonymousChecked, + postCategory = "FREE", + file = photo + ).onSuccess { + postSideEffect(PostWriteEffect.OnShowSnackbar("게시글 작성에 성공했어요 :)")) + postSideEffect(PostWriteEffect.NavigateBack) + }.onFailure { + postSideEffect(PostWriteEffect.OnShowSnackbar(it.message ?: "게시글 작성에 실패했어요 ;(")) + } + } else { + postSideEffect(PostWriteEffect.OnShowSnackbar("학급 정보를 불러오지 못했어요 ;(")) + } + + intent { copy(isLoading = false) } + } + } +} \ No newline at end of file diff --git a/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/post/postwrite/component/PageTitle.kt b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/post/postwrite/component/PageTitle.kt new file mode 100644 index 00000000..c777acdd --- /dev/null +++ b/android/feature/teacher/board/src/main/java/com/sixkids/teacher/board/post/postwrite/component/PageTitle.kt @@ -0,0 +1,55 @@ +package com.sixkids.teacher.board.post.postwrite.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.designsystem.R as UlbanRes + +@Composable +fun PageTitle( + modifier: Modifier = Modifier, + title: String, + cancelOnclick: () -> Unit = {}, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + modifier = Modifier + .clickable { cancelOnclick() }, + imageVector = ImageVector.vectorResource(id = UlbanRes.drawable.ic_cancel_post), + contentDescription = null + ) + Spacer(modifier = Modifier.width(14.dp)) + Text( + text = title, + style = UlbanTypography.titleLarge.copy( + fontWeight = FontWeight.Medium, + ), + ) + } +} + + +@Preview(showBackground = true) +@Composable +fun PageTitlePreview() { + PageTitle( + title = "글 쓰기", + cancelOnclick = {} + ) +} \ No newline at end of file diff --git a/android/feature/teacher/board/src/main/res/drawable/ic_camera.xml b/android/feature/teacher/board/src/main/res/drawable/ic_camera.xml new file mode 100644 index 00000000..7283b1a9 --- /dev/null +++ b/android/feature/teacher/board/src/main/res/drawable/ic_camera.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/feature/teacher/board/src/main/res/values/strings.xml b/android/feature/teacher/board/src/main/res/values/strings.xml new file mode 100644 index 00000000..f9f61225 --- /dev/null +++ b/android/feature/teacher/board/src/main/res/values/strings.xml @@ -0,0 +1,22 @@ + + + 게시판 + 알림장 + 자유게시판 + 채팅 + + 글쓰기 + 제목 + 내용을 입력하세요 + 익명 + 게시 + + 게시글이 없어요! + + 댓글을 입력하세요 + + 알림장이 없어용! + + 알림장 쓰기 + + \ No newline at end of file diff --git a/android/feature/teacher/challenge/.gitignore b/android/feature/teacher/challenge/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/android/feature/teacher/challenge/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/android/feature/teacher/challenge/build.gradle.kts b/android/feature/teacher/challenge/build.gradle.kts new file mode 100644 index 00000000..38b61be1 --- /dev/null +++ b/android/feature/teacher/challenge/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + alias(libs.plugins.sixkids.android.feature.compose) +} + +android { + namespace = "com.sixkids.teacher.challenge" +} + +dependencies { + implementation(libs.coil.compose) + implementation(libs.paging.compose) +} diff --git a/android/feature/teacher/challenge/consumer-rules.pro b/android/feature/teacher/challenge/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/android/feature/teacher/challenge/proguard-rules.pro b/android/feature/teacher/challenge/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/android/feature/teacher/challenge/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/android/app/src/androidTest/java/com/sixkids/ulban/ExampleInstrumentedTest.kt b/android/feature/teacher/challenge/src/androidTest/java/com/sixkids/challenge/ExampleInstrumentedTest.kt similarity index 84% rename from android/app/src/androidTest/java/com/sixkids/ulban/ExampleInstrumentedTest.kt rename to android/feature/teacher/challenge/src/androidTest/java/com/sixkids/challenge/ExampleInstrumentedTest.kt index 5c740139..166553a6 100644 --- a/android/app/src/androidTest/java/com/sixkids/ulban/ExampleInstrumentedTest.kt +++ b/android/feature/teacher/challenge/src/androidTest/java/com/sixkids/challenge/ExampleInstrumentedTest.kt @@ -1,4 +1,4 @@ -package com.sixkids.ulban +package com.sixkids.challenge import androidx.test.platform.app.InstrumentationRegistry import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -19,6 +19,6 @@ class ExampleInstrumentedTest { fun useAppContext() { // Context of the app under test. val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.sixkids.ulban", appContext.packageName) + assertEquals("com.sixkids.challenge.test", appContext.packageName) } } diff --git a/android/feature/teacher/challenge/src/main/AndroidManifest.xml b/android/feature/teacher/challenge/src/main/AndroidManifest.xml new file mode 100644 index 00000000..12304050 --- /dev/null +++ b/android/feature/teacher/challenge/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/create/ChallengeCreateContract.kt b/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/create/ChallengeCreateContract.kt new file mode 100644 index 00000000..d7c9d7c7 --- /dev/null +++ b/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/create/ChallengeCreateContract.kt @@ -0,0 +1,31 @@ +package com.sixkids.teacher.challenge.create + +import androidx.annotation.StringRes +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState + +data class ChallengeCreateUiState( + val isLoading: Boolean = false, + val buttonEnabled: Boolean = false, + @StringRes val buttonText: Int? = null, + val step: ChallengeCreateStep = ChallengeCreateStep.INFO, + val organizationId: Int = 0, +) : UiState + +sealed interface ChallengeCreateEffect : SideEffect { + data object NavigateUp : ChallengeCreateEffect + data class NavigateResult(val challengeId: Long, val title: String) : ChallengeCreateEffect + data class ShowSnackbar(val snackbarToken: SnackbarToken) : ChallengeCreateEffect + + data class HandleException(val throwable: Throwable, val retry: () -> Unit) : + ChallengeCreateEffect +} + +enum class ChallengeCreateStep { + INFO, + GROUP_TYPE, + MATCHING_TYPE, + MATCHING_SUCCESS, + RESULT, +} diff --git a/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/create/ChallengeCreateScreen.kt b/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/create/ChallengeCreateScreen.kt new file mode 100644 index 00000000..98ad7010 --- /dev/null +++ b/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/create/ChallengeCreateScreen.kt @@ -0,0 +1,157 @@ +package com.sixkids.teacher.challenge.create + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.sixkids.designsystem.theme.UlbanTheme +import com.sixkids.model.ChallengeGroup +import com.sixkids.teacher.challenge.create.grouptype.GroupType +import com.sixkids.teacher.challenge.create.grouptype.GroupTypeRoute +import com.sixkids.teacher.challenge.create.info.InfoContentRoute +import com.sixkids.teacher.challenge.create.matching.GroupMatchingSettingRoute +import com.sixkids.teacher.challenge.create.matching.GroupMatchingSuccessRoute +import com.sixkids.teacher.challenge.create.matching.MatchingSource +import com.sixkids.teacher.challenge.create.matching.MatchingType +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.extension.collectWithLifecycle +import java.time.LocalDateTime + + +@Composable +fun ChallengeCreateRoute( + viewModel: ChallengeCreateViewModel = hiltViewModel(), + onNavigateResult: (Long, String) -> Unit, + onNavigateUp: () -> Unit, + onHandleException: (Throwable, () -> Unit) -> Unit, + onShowSnackbar: (SnackbarToken) -> Unit +) { + + val uiState = viewModel.uiState.collectAsStateWithLifecycle().value + + viewModel.sideEffect.collectWithLifecycle { + when (it) { + is ChallengeCreateEffect.ShowSnackbar -> onShowSnackbar(it.snackbarToken) + is ChallengeCreateEffect.NavigateResult -> onNavigateResult(it.challengeId, it.title) + ChallengeCreateEffect.NavigateUp -> onNavigateUp() + is ChallengeCreateEffect.HandleException -> onHandleException(it.throwable, it.retry) + } + } + + LaunchedEffect(Unit) { + viewModel.initData() + } + + ChallengeCreateScreen( + uiState = uiState, + updateTitle = viewModel::updateTitle, + updateContent = viewModel::updateContent, + updateStartTime = viewModel::updateStartTime, + updateEndTime = viewModel::updateEndTime, + updatePoint = viewModel::updatePoint, + updateCount = viewModel::updateCount, + updateMatchingMemberList = viewModel::updateMatchingMemberList, + updateMatchingType = viewModel::updateMatchingType, + updateGroupType = viewModel::updateGroupType, + updateGroupList = viewModel::updateGroupList, + onMoveNextStep = viewModel::moveNextStep, + onMovePrevStep = viewModel::movePrevStep, + onGetMatchingGroupList = viewModel::getMatchingGroupList, + onShowSnackbar = viewModel::onShowSnackbar, + createChallenge = viewModel::createChallenge, + ) +} + +@Composable +fun ChallengeCreateScreen( + uiState: ChallengeCreateUiState, + updateTitle: (String) -> Unit = {}, + updateContent: (String) -> Unit = {}, + updateStartTime: (LocalDateTime) -> Unit = {}, + updateEndTime: (LocalDateTime) -> Unit = {}, + updatePoint: (String) -> Unit = {}, + onShowSnackbar: (SnackbarToken) -> Unit = {}, + updateCount: (String) -> Unit = {}, + updateMatchingMemberList: (List) -> Unit = {}, + updateMatchingType: (MatchingType) -> Unit = {}, + updateGroupType: (GroupType) -> Unit = {}, + updateGroupList: (List) -> Unit = {}, + onGetMatchingGroupList: () -> (MatchingSource) = { MatchingSource() }, + onMoveNextStep: () -> Unit = {}, + onMovePrevStep: () -> Unit = {}, + createChallenge: () -> Unit = {}, +) { + + BackHandler { + onMovePrevStep() + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(top = 40.dp, start = 20.dp, end = 20.dp, bottom = 20.dp), + ) { + AnimatedContent( + modifier = Modifier.weight(1f), + targetState = uiState.step, + label = "ChallengeCreateScreen", + ) { targetState -> + when (targetState) { + ChallengeCreateStep.INFO -> InfoContentRoute( + updateTitle = updateTitle, + updateContent = updateContent, + updateStartTime = updateStartTime, + updateEndTime = updateEndTime, + updatePoint = updatePoint, + onShowSnackbar = onShowSnackbar, + moveNextStep = onMoveNextStep, + ) + + ChallengeCreateStep.GROUP_TYPE -> GroupTypeRoute( + updateMinCount = updateCount, + updateGroupType = updateGroupType, + moveNextStep = onMoveNextStep, + createChallenge = createChallenge, + onShowSnackbar = onShowSnackbar, + ) + + ChallengeCreateStep.MATCHING_TYPE -> GroupMatchingSettingRoute( + moveNextStep = onMoveNextStep, + onUpdateMatchingMemberList = updateMatchingMemberList, + onUpdateMatchingType = updateMatchingType, + onShowSnackbar = onShowSnackbar, + ) + + ChallengeCreateStep.MATCHING_SUCCESS -> GroupMatchingSuccessRoute( + onShowSnackbar = onShowSnackbar, + createChallenge = createChallenge, + updateGroupList = updateGroupList, + onGetMatchingGroupList = onGetMatchingGroupList, + ) + else -> { + + } + } + } + + } + +} + +@Preview(showBackground = true) +@Composable +fun ChallengeCreateScreenPreview() { + UlbanTheme { + ChallengeCreateScreen( + uiState = ChallengeCreateUiState(), + ) + } +} diff --git a/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/create/ChallengeCreateViewModel.kt b/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/create/ChallengeCreateViewModel.kt new file mode 100644 index 00000000..d29c046f --- /dev/null +++ b/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/create/ChallengeCreateViewModel.kt @@ -0,0 +1,160 @@ +package com.sixkids.teacher.challenge.create + +import androidx.lifecycle.viewModelScope +import com.sixkids.domain.usecase.challenge.CreateChallengeUseCase +import com.sixkids.domain.usecase.organization.GetSelectedOrganizationIdUseCase +import com.sixkids.model.ChallengeGroup +import com.sixkids.model.GroupSimple +import com.sixkids.teacher.challenge.create.grouptype.GroupType +import com.sixkids.teacher.challenge.create.matching.MatchingSource +import com.sixkids.teacher.challenge.create.matching.MatchingType +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import java.time.LocalDateTime +import javax.inject.Inject + +@HiltViewModel +class ChallengeCreateViewModel @Inject constructor( + private val createChallengeUseCase: CreateChallengeUseCase, + private val getSelectedOrganizationIdUseCase: GetSelectedOrganizationIdUseCase +) : BaseViewModel( + ChallengeCreateUiState() +) { + + private var isFirstVisited: Boolean = true + fun initData() { + viewModelScope.launch { + if (isFirstVisited.not()) return@launch + isFirstVisited = false + + getSelectedOrganizationIdUseCase().onSuccess { + intent { + copy(organizationId = it) + } + }.onFailure { + postSideEffect(ChallengeCreateEffect.HandleException(it) { initData() }) + } + } + } + + private var title: String = "" + private var content: String = "" + private var startTime: LocalDateTime = LocalDateTime.now() + private var endTime: LocalDateTime = LocalDateTime.now() + private var point: String = "" + private var headCount: String = "" + private var matchingMemberList: List = emptyList() + private var groupMatchingType: MatchingType = MatchingType.FRIENDLY + private var groupType: GroupType = GroupType.FREE + private var groupList: List = emptyList() + + fun createChallenge() { + viewModelScope.launch { + createChallengeUseCase( + organizationId = uiState.value.organizationId, + title = title, + content = content, + startTime = startTime, + endTime = endTime, + reward = point.toInt(), + minCount = headCount.toInt(), + groups = groupList + ).onSuccess { challengeId -> + postSideEffect(ChallengeCreateEffect.NavigateResult(challengeId, title)) + }.onFailure { + onShowSnackbar(SnackbarToken("챌린지 생성에 실패했습니다.")) + } + } + } + + fun moveNextStep() { + intent { + when (step) { + ChallengeCreateStep.INFO -> copy(step = ChallengeCreateStep.GROUP_TYPE) + ChallengeCreateStep.GROUP_TYPE -> copy(step = ChallengeCreateStep.MATCHING_TYPE) + ChallengeCreateStep.MATCHING_TYPE -> copy(step = ChallengeCreateStep.MATCHING_SUCCESS) + ChallengeCreateStep.MATCHING_SUCCESS -> copy(step = ChallengeCreateStep.RESULT) + else -> copy() + } + } + } + + fun movePrevStep() { + intent { + when (step) { + ChallengeCreateStep.INFO -> { + postSideEffect(ChallengeCreateEffect.NavigateUp) + copy() + } + + ChallengeCreateStep.GROUP_TYPE -> copy(step = ChallengeCreateStep.INFO) + ChallengeCreateStep.MATCHING_TYPE -> copy(step = ChallengeCreateStep.GROUP_TYPE) + ChallengeCreateStep.MATCHING_SUCCESS -> copy(step = ChallengeCreateStep.MATCHING_TYPE) + else -> copy() + } + } + } + + + fun onShowSnackbar(snackbarToken: SnackbarToken) { + postSideEffect(ChallengeCreateEffect.ShowSnackbar(snackbarToken)) + } + + fun updateTitle(title: String) { + this.title = title + } + + fun updateContent(content: String) { + this.content = content + } + + fun updateStartTime(startTime: LocalDateTime) { + this.startTime = startTime + } + + fun updateEndTime(endTime: LocalDateTime) { + this.endTime = endTime + } + + fun updatePoint(point: String) { + this.point = point + } + + fun updateCount(count: String) { + this.headCount = count + } + + fun updateGroupType(groupType: GroupType) { + this.groupType = groupType + } + + fun updateMatchingMemberList(matchingMemberList: List) { + this.matchingMemberList = matchingMemberList + } + + fun updateMatchingType(matchingType: MatchingType) { + this.groupMatchingType = matchingType + } + + fun getMatchingGroupList(): MatchingSource { + return MatchingSource( + orgId = uiState.value.organizationId.toLong(), + minCount = headCount.toInt(), + matchingType = groupMatchingType, + members = matchingMemberList + ) + } + + fun updateGroupList(challengeGroups: List) { + this.groupList = challengeGroups.map { group -> + GroupSimple( + headCount = group.headCount, + leaderId = group.memberList.first().id, + students = group.memberList.map { it.id } + ) + } + } + +} diff --git a/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/create/grouptype/GroupTypeContent.kt b/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/create/grouptype/GroupTypeContent.kt new file mode 100644 index 00000000..aaab6d22 --- /dev/null +++ b/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/create/grouptype/GroupTypeContent.kt @@ -0,0 +1,201 @@ +package com.sixkids.teacher.challenge.create.grouptype + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +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.selection.selectable +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.sixkids.designsystem.component.button.UlbanFilledButton +import com.sixkids.designsystem.component.textfield.InputTextType +import com.sixkids.designsystem.component.textfield.UlbanUnderLineTextField +import com.sixkids.designsystem.theme.UlbanTheme +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.teacher.challenge.R +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.extension.collectWithLifecycle + + +@Composable +fun GroupTypeRoute( + viewModel: GroupTypeViewModel = hiltViewModel(), + updateMinCount: (String) -> Unit, + updateGroupType: (GroupType) -> Unit, + moveNextStep: () -> Unit, + createChallenge: () -> Unit, + onShowSnackbar: (SnackbarToken) -> Unit +) { + + val uiState = viewModel.uiState.collectAsStateWithLifecycle().value + + val context = LocalContext.current + viewModel.sideEffect.collectWithLifecycle { + when (it) { + is GroupTypeEffect.ShowInputErrorSnackbar -> onShowSnackbar( + SnackbarToken( + message = context.getString(R.string.please_input_min_head_count), + ) + ) + + is GroupTypeEffect.MoveToMatchingStep -> moveNextStep() + is GroupTypeEffect.UpdateGroupType -> updateGroupType(it.type) + is GroupTypeEffect.UpdateMinCount -> updateMinCount(it.minCount) + GroupTypeEffect.CreateChallenge -> createChallenge() + } + } + + GroupTypeScreen( + uiState = uiState, + updateMinCount = viewModel::updateMinCount, + updateGroupType = viewModel::updateGroupType, + moveNextStep = viewModel::moveNextStep, + moveToMatchingStep = viewModel::moveToMatchingStep, + createChallenge = viewModel::createChallenge + ) +} + +@Composable +fun GroupTypeScreen( + uiState: GroupTypeState, + updateMinCount: (String) -> Unit = {}, + updateGroupType: (GroupType) -> Unit = {}, + createChallenge: () -> Unit = {}, + moveNextStep: () -> Unit = {}, + moveToMatchingStep: () -> Unit = {}, +) { + + val minCountFocusRequester = remember { FocusRequester() } + val focusManager = LocalFocusManager.current + + val radioOptions = GroupType.entries + val (selectedOption, onOptionSelected) = remember { mutableStateOf(radioOptions[0]) } + + fun handelNext() { + if (uiState.groutTypeVisibility.not()) { + moveNextStep() + } else if (uiState.groutTypeVisibility && uiState.groutType == GroupType.FREE) { + createChallenge() + } else { + moveToMatchingStep() + } + } + LaunchedEffect(key1 = selectedOption) { + updateGroupType(selectedOption) + } + + Column( + modifier = Modifier + .fillMaxSize() + .pointerInput(Unit) { + detectTapGestures( + onPress = { focusManager.clearFocus() } + ) + }, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + AnimatedVisibility(uiState.groutTypeVisibility) { + Column(modifier = Modifier.padding(bottom = 16.dp)) { + Text( + text = stringResource(R.string.please_select_group_type), + style = UlbanTypography.titleSmall + ) + radioOptions.forEach { option -> + Row( + Modifier + .fillMaxWidth() + .selectable( + selected = (option == selectedOption), + onClick = { onOptionSelected(option) }, + role = Role.RadioButton + ) + .padding(start = 8.dp, top = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = (option == selectedOption), + onClick = null + ) + Text( + text = stringResource(option.textRes), + style = UlbanTypography.bodyMedium, + modifier = Modifier.padding(start = 16.dp) + ) + } + } + } + } + Column { + Text( + text = stringResource(R.string.please_input_min_head_count), + style = UlbanTypography.titleSmall + ) + UlbanUnderLineTextField( + modifier = Modifier + .focusRequester(minCountFocusRequester), + text = uiState.minCount, + onTextChange = { updateMinCount(it) }, + onIconClick = { updateMinCount("") }, + inputTextType = InputTextType.PEOPLE, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { + handelNext() + } + ) + ) + } + Spacer(modifier = Modifier.weight(1f)) + UlbanFilledButton( + text = if (uiState.groutType == GroupType.FREE) + stringResource(R.string.confirm) + else + stringResource(R.string.next), + onClick = { + handelNext() + }, + modifier = Modifier + .fillMaxWidth() + ) + } + + +} + + +@Preview(showBackground = true) +@Composable +fun GroupTypeScreenPreview() { + UlbanTheme { + GroupTypeScreen( + GroupTypeState(groutTypeVisibility = true) + ) + } +} diff --git a/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/create/grouptype/GroupTypeContract.kt b/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/create/grouptype/GroupTypeContract.kt new file mode 100644 index 00000000..c354d228 --- /dev/null +++ b/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/create/grouptype/GroupTypeContract.kt @@ -0,0 +1,28 @@ +package com.sixkids.teacher.challenge.create.grouptype + +import androidx.annotation.StringRes +import com.sixkids.teacher.challenge.R +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState + +data class GroupTypeState( + val minCount: String = "", + val groutType: GroupType = GroupType.FREE, + val groutTypeVisibility: Boolean = false, +) : UiState + +sealed interface GroupTypeEffect : SideEffect { + + data class UpdateMinCount(val minCount: String) : GroupTypeEffect + data class UpdateGroupType(val type: GroupType) : GroupTypeEffect + data object MoveToMatchingStep : GroupTypeEffect + data object CreateChallenge : GroupTypeEffect + data object ShowInputErrorSnackbar : GroupTypeEffect +} + +enum class GroupType( + @StringRes val textRes: Int +) { + FREE(R.string.free_group), + GROUP(R.string.matching_group), +} diff --git a/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/create/grouptype/GroupTypeViewModel.kt b/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/create/grouptype/GroupTypeViewModel.kt new file mode 100644 index 00000000..460aaac5 --- /dev/null +++ b/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/create/grouptype/GroupTypeViewModel.kt @@ -0,0 +1,48 @@ +package com.sixkids.teacher.challenge.create.grouptype + +import com.sixkids.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class GroupTypeViewModel @Inject constructor( + +) : BaseViewModel( + GroupTypeState() +) { + fun createChallenge() { + if ((uiState.value.minCount.toIntOrNull() ?: 0) == 0) { + postSideEffect(GroupTypeEffect.ShowInputErrorSnackbar) + } else { + postSideEffect(GroupTypeEffect.CreateChallenge) + } + } + + fun moveNextStep() { + intent { + copy(groutTypeVisibility = true) + } + } + + fun moveToMatchingStep() { + if ((uiState.value.minCount.toIntOrNull() ?: 0) == 0) { + postSideEffect(GroupTypeEffect.ShowInputErrorSnackbar) + } else { + postSideEffect(GroupTypeEffect.MoveToMatchingStep) + } + } + + fun updateGroupType(groupType: GroupType) { + intent { + postSideEffect(GroupTypeEffect.UpdateGroupType(groupType)) + copy(groutType = groupType) + } + } + + fun updateMinCount(count: String) { + intent { + postSideEffect(GroupTypeEffect.UpdateMinCount(count)) + copy(minCount = count) + } + } +} diff --git a/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/create/info/InfoContent.kt b/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/create/info/InfoContent.kt new file mode 100644 index 00000000..cd0b2486 --- /dev/null +++ b/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/create/info/InfoContent.kt @@ -0,0 +1,395 @@ +package com.sixkids.teacher.challenge.create.info + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +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.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Text +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.sixkids.designsystem.component.button.UlbanFilledButton +import com.sixkids.designsystem.component.dialog.UlbanDatePickerDialog +import com.sixkids.designsystem.component.dialog.UlbanTimePickerDialog +import com.sixkids.designsystem.component.textfield.InputTextType +import com.sixkids.designsystem.component.textfield.UlbanUnderLineIconInputField +import com.sixkids.designsystem.component.textfield.UlbanUnderLineTextField +import com.sixkids.designsystem.theme.UlbanTheme +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.teacher.challenge.R +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.extension.collectWithLifecycle +import com.sixkids.ui.util.formatToDayMonthYear +import com.sixkids.ui.util.formatToHourMinute +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import com.sixkids.designsystem.R as DesignSystemR + +private const val TAG = "D107" + +@Composable +fun InfoContentRoute( + viewModel: InfoViewModel = hiltViewModel(), + updateTitle: (String) -> Unit, + updateContent: (String) -> Unit, + updateStartTime: (LocalDateTime) -> Unit, + updateEndTime: (LocalDateTime) -> Unit, + updatePoint: (String) -> Unit, + onShowSnackbar: (SnackbarToken) -> Unit, + moveNextStep: () -> Unit, +) { + + val context = LocalContext.current + + val uiState = viewModel.uiState.collectAsStateWithLifecycle().value + LaunchedEffect(key1 = Unit) { + viewModel.setInitVisibility() + val startTime = LocalDateTime.of(uiState.startDate, uiState.startTime) + val endTime = LocalDateTime.of(uiState.endDate, uiState.endTime) + updateStartTime(startTime) + updateEndTime(endTime) + } + + + viewModel.sideEffect.collectWithLifecycle { + when (it) { + is InfoEffect.UpdateTitle -> updateTitle(it.title) + is InfoEffect.UpdateContent -> updateContent(it.content) + is InfoEffect.UpdateStartTime -> updateStartTime(it.startTime) + is InfoEffect.UpdateEndTime -> updateEndTime(it.endTime) + is InfoEffect.UpdatePoint -> updatePoint(it.point) + is InfoEffect.ShowInputErrorSnackbar -> onShowSnackbar( + SnackbarToken( + message = context.getString(it.messageRes) + ) + ) + + InfoEffect.MoveGroupTypeStep -> { + moveNextStep() + } + } + } + + InfoContent( + uiState = uiState, + updateTitle = viewModel::updateTitle, + updateContent = viewModel::updateContent, + updatePoint = viewModel::updatePoint, + updateStartDate = viewModel::updateStartDate, + updateEndDate = viewModel::updateEndDate, + updateStartTime = viewModel::updateStartTime, + updateEndTime = viewModel::updateEndTime, + moveNextInput = viewModel::moveNextInput, + moveNextStep = viewModel::moveNextStep, + focusChange = viewModel::focusChange + ) + +} + +@Composable +fun InfoContent( + uiState: InfoState = InfoState(), + updateTitle: (String) -> Unit = {}, + updateContent: (String) -> Unit = {}, + updateStartDate: (LocalDate) -> Unit = {}, + updateEndDate: (LocalDate) -> Unit = {}, + updateStartTime: (LocalTime) -> Unit = {}, + updateEndTime: (LocalTime) -> Unit = {}, + updatePoint: (String) -> Unit = {}, + moveNextInput: () -> Unit = {}, + moveNextStep: () -> Unit = {}, + focusChange: (InfoStep) -> Unit = {} +) { + + val titleFocusRequester = remember { FocusRequester() } + val contentFocusRequester = remember { FocusRequester() } + val pointFocusRequester = remember { FocusRequester() } + + + val focusManager = LocalFocusManager.current + + val handelNext: () -> Unit = { + if (uiState.step != InfoStep.POINT) { + if (uiState.step == InfoStep.CONTENT && uiState.stepVisibilityList[InfoStep.POINT.ordinal].not()) { + focusManager.clearFocus() + } + moveNextInput() + } else { + moveNextStep() + } + } + + + + LaunchedEffect(key1 = uiState.step) { + if (uiState.stepVisibilityList.isNotEmpty() && uiState.stepVisibilityList[uiState.step.ordinal]) { + when (uiState.step) { + InfoStep.TITLE -> titleFocusRequester.requestFocus() + InfoStep.CONTENT -> contentFocusRequester.requestFocus() + InfoStep.POINT -> pointFocusRequester.requestFocus() + else -> { + if (uiState.stepVisibilityList[InfoStep.POINT.ordinal]) { + pointFocusRequester.requestFocus() + } + } + } + } + } + + + Column( + modifier = Modifier + .fillMaxSize() + .pointerInput(Unit) { + detectTapGestures( + onPress = { focusManager.clearFocus() } + ) + }, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + if (uiState.stepVisibilityList.isNotEmpty()) { + AnimatedVisibility(uiState.stepVisibilityList[InfoStep.POINT.ordinal]) { + Column(modifier = Modifier.padding(bottom = 16.dp)) { + Text( + text = stringResource(R.string.please_input_point), + style = UlbanTypography.titleSmall + ) + UlbanUnderLineTextField( + modifier = Modifier + .focusRequester(pointFocusRequester) + .onFocusChanged { + focusChange(InfoStep.POINT) + }, + text = uiState.point, + onTextChange = { updatePoint(it) }, + hint = stringResource(R.string.point_hint), + onIconClick = { updatePoint("") }, + inputTextType = InputTextType.POINT, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { + handelNext() + } + ) + ) + } + LaunchedEffect(key1 = uiState.stepVisibilityList[InfoStep.POINT.ordinal]) { + pointFocusRequester.requestFocus() + } + } + AnimatedVisibility(uiState.stepVisibilityList[InfoStep.END_TIME.ordinal]) { + var showDateDialog by remember { mutableStateOf(false) } + var showTimeDialog by remember { mutableStateOf(false) } + Column(modifier = Modifier.padding(bottom = 16.dp)) { + Text( + text = stringResource(R.string.please_input_end_time), + style = UlbanTypography.titleSmall + ) + Row(modifier = Modifier.fillMaxWidth()) { + UlbanUnderLineIconInputField( + modifier = Modifier.weight(3f), + text = uiState.endDate.formatToDayMonthYear(), + iconResource = DesignSystemR.drawable.ic_calendar, + onIconClick = { + showDateDialog = true + } + ) + Spacer(modifier = Modifier.weight(1f)) + UlbanUnderLineIconInputField( + modifier = Modifier.weight(2f), + text = uiState.endTime.formatToHourMinute(), + iconResource = DesignSystemR.drawable.ic_time, + onIconClick = { + showTimeDialog = true + } + ) + } + if (showDateDialog) { + UlbanDatePickerDialog( + selectedDate = uiState.endDate, + onDismiss = { + showDateDialog = false + }, + onClickConfirm = { + showDateDialog = false + updateEndDate(it) + } + ) + } + if (showTimeDialog) { + UlbanTimePickerDialog( + selectedTime = uiState.endTime, + onDismiss = { + showTimeDialog = false + }, + onClickConfirm = { + showTimeDialog = false + updateEndTime(it) + } + ) + } + } + } + AnimatedVisibility(uiState.stepVisibilityList[InfoStep.START_TIME.ordinal]) { + var showDateDialog by remember { mutableStateOf(false) } + var showTimeDialog by remember { mutableStateOf(false) } + Column(modifier = Modifier.padding(bottom = 16.dp)) { + Text( + text = stringResource(R.string.please_input_start_time), + style = UlbanTypography.titleSmall + ) + Row(modifier = Modifier.fillMaxWidth()) { + UlbanUnderLineIconInputField( + modifier = Modifier.weight(3f), + text = uiState.startDate.formatToDayMonthYear(), + iconResource = DesignSystemR.drawable.ic_calendar, + onIconClick = { + showDateDialog = true + } + ) + Spacer(modifier = Modifier.weight(1f)) + UlbanUnderLineIconInputField( + modifier = Modifier.weight(2f), + text = uiState.startTime.formatToHourMinute(), + iconResource = DesignSystemR.drawable.ic_time, + onIconClick = { + showTimeDialog = true + } + ) + } + } + if (showDateDialog) { + UlbanDatePickerDialog( + selectedDate = uiState.startDate, + onDismiss = { + showDateDialog = false + }, + onClickConfirm = { + showDateDialog = false + updateStartDate(it) + } + ) + } + if (showTimeDialog) { + UlbanTimePickerDialog( + selectedTime = uiState.startTime, + onDismiss = { + showTimeDialog = false + }, + onClickConfirm = { + showTimeDialog = false + updateStartTime(it) + } + ) + } + } + AnimatedVisibility(uiState.stepVisibilityList[InfoStep.CONTENT.ordinal]) { + Column(modifier = Modifier.padding(bottom = 16.dp)) { + Text( + text = stringResource(R.string.please_input_content), + style = UlbanTypography.titleSmall + ) + UlbanUnderLineTextField( + modifier = Modifier + .focusRequester(contentFocusRequester) + .onFocusChanged { + focusChange(InfoStep.CONTENT) + }, + text = uiState.content, + onTextChange = { updateContent(it) }, + onIconClick = { updateContent("") }, + inputTextType = InputTextType.TEXT, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { + handelNext() + } + ) + ) + } + LaunchedEffect(key1 = uiState.stepVisibilityList[InfoStep.CONTENT.ordinal]) { + contentFocusRequester.requestFocus() + } + } + AnimatedVisibility(uiState.stepVisibilityList[InfoStep.TITLE.ordinal]) { + Column { + Text( + text = stringResource(R.string.please_input_title), + style = UlbanTypography.titleSmall + ) + UlbanUnderLineTextField( + modifier = Modifier + .focusRequester(titleFocusRequester) + .onFocusChanged { + focusChange(InfoStep.TITLE) + }, + text = uiState.title, + onTextChange = { updateTitle(it) }, + onIconClick = { updateTitle("") }, + inputTextType = InputTextType.TEXT, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { + handelNext() + } + ) + ) + } + LaunchedEffect(key1 = uiState.stepVisibilityList[InfoStep.TITLE.ordinal]) { + titleFocusRequester.requestFocus() + } + } + } + Spacer(modifier = Modifier.weight(1f)) + UlbanFilledButton( + text = stringResource(R.string.next), + onClick = { + handelNext() + }, + modifier = Modifier + .fillMaxWidth() + ) + } +} + +@Preview(showBackground = true) +@Composable +fun InfoContentPreview() { + UlbanTheme { + InfoContent( + uiState = InfoState( + stepVisibilityList = listOf(true, true, true, true, true), + ) + ) + } +} diff --git a/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/create/info/InfoContract.kt b/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/create/info/InfoContract.kt new file mode 100644 index 00000000..2626efe8 --- /dev/null +++ b/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/create/info/InfoContract.kt @@ -0,0 +1,39 @@ +package com.sixkids.teacher.challenge.create.info + +import androidx.annotation.StringRes +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime + +data class InfoState( + val title: String = "", + val content: String = "", + val startDate: LocalDate = LocalDate.now(), + val endDate: LocalDate = LocalDate.now(), + val startTime: LocalTime = LocalTime.now().plusMinutes(5), + val endTime: LocalTime = LocalTime.now().plusMinutes(5), + val point: String = "", + val step: InfoStep = InfoStep.TITLE, + val stepVisibilityList: List = emptyList() +) : UiState + +sealed interface InfoEffect : SideEffect { + + data class UpdateTitle(val title: String) : InfoEffect + data class UpdateContent(val content: String) : InfoEffect + data class UpdateStartTime(val startTime: LocalDateTime) : InfoEffect + data class UpdateEndTime(val endTime: LocalDateTime) : InfoEffect + data class UpdatePoint(val point: String) : InfoEffect + data object MoveGroupTypeStep : InfoEffect + data class ShowInputErrorSnackbar(@StringRes val messageRes: Int) : InfoEffect +} + +enum class InfoStep { + TITLE, + CONTENT, + START_TIME, + END_TIME, + POINT +} diff --git a/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/create/info/InfoViewModel.kt b/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/create/info/InfoViewModel.kt new file mode 100644 index 00000000..a32aa77d --- /dev/null +++ b/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/create/info/InfoViewModel.kt @@ -0,0 +1,161 @@ +package com.sixkids.teacher.challenge.create.info + +import com.sixkids.teacher.challenge.R +import com.sixkids.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import javax.inject.Inject + +@HiltViewModel +class InfoViewModel @Inject constructor( + +) : BaseViewModel( + InfoState() +) { + fun updateTitle(title: String) { + intent { + postSideEffect(InfoEffect.UpdateTitle(title)) + copy(title = title) + } + } + + fun updateContent(content: String) { + intent { + postSideEffect(InfoEffect.UpdateContent(content)) + copy(content = content) + } + } + + fun updatePoint(point: String) { + intent { + postSideEffect(InfoEffect.UpdatePoint(point)) + copy(point = point) + } + } + + + fun updateStartDate(startDate: LocalDate) { + intent { + val selectedTime = LocalDateTime.of(startDate, startTime) + postSideEffect(InfoEffect.UpdateStartTime(selectedTime)) + copy(startDate = startDate) + } + } + + fun updateEndDate(endDate: LocalDate) { + intent { + val selectedTime = LocalDateTime.of(endDate, endTime) + postSideEffect(InfoEffect.UpdateEndTime(selectedTime)) + copy(endDate = endDate) + } + } + + fun updateStartTime(startTime: LocalTime) { + intent { + val selectedTime = LocalDateTime.of(startDate, startTime) + postSideEffect(InfoEffect.UpdateStartTime(selectedTime)) + copy(startTime = startTime) + } + } + + fun updateEndTime(endTime: LocalTime) { + intent { + val selectedTime = LocalDateTime.of(endDate, endTime) + postSideEffect(InfoEffect.UpdateEndTime(selectedTime)) + copy(endTime = endTime) + } + } + + fun moveNextInput() { + when (uiState.value.step) { + InfoStep.TITLE -> { + intent { + copy( + step = InfoStep.CONTENT, + stepVisibilityList = uiState.value.stepVisibilityList.toMutableList() + .apply { + this[InfoStep.CONTENT.ordinal] = true + }) + } + } + + InfoStep.CONTENT -> { + intent { + copy( + step = InfoStep.START_TIME, + stepVisibilityList = uiState.value.stepVisibilityList.toMutableList() + .apply { + this[InfoStep.START_TIME.ordinal] = true + } + ) + } + } + + InfoStep.START_TIME -> { + intent { + copy( + step = InfoStep.END_TIME, + stepVisibilityList = uiState.value.stepVisibilityList.toMutableList() + .apply { + this[InfoStep.END_TIME.ordinal] = true + }) + } + } + + InfoStep.END_TIME -> { + intent { + copy( + step = InfoStep.POINT, + stepVisibilityList = uiState.value.stepVisibilityList.toMutableList() + .apply { + this[InfoStep.POINT.ordinal] = true + }) + } + } + + InfoStep.POINT -> { + postSideEffect(InfoEffect.MoveGroupTypeStep) + } + + } + } + + fun setInitVisibility() { + if (uiState.value.stepVisibilityList.isNotEmpty()) return + intent { + copy( + stepVisibilityList = MutableList(InfoStep.entries.size) { false }.apply { + this[InfoStep.TITLE.ordinal] = true + } + ) + } + } + + fun moveNextStep() { + val startTime = LocalDateTime.of(uiState.value.startDate, uiState.value.startTime) + val endTime = LocalDateTime.of(uiState.value.endDate, uiState.value.endTime) + if ( + uiState.value.title.isEmpty() || + uiState.value.content.isEmpty() || + uiState.value.point.isEmpty() + ) { + postSideEffect(InfoEffect.ShowInputErrorSnackbar(R.string.please_input_all_info)) + } else if (startTime.isAfter(endTime)) { + postSideEffect(InfoEffect.ShowInputErrorSnackbar(R.string.end_time_earlier_than_start_time)) + } else if(startTime.isBefore(LocalDateTime.now())) { + postSideEffect(InfoEffect.ShowInputErrorSnackbar(R.string.start_time_earlier_than_now)) + } + else { + postSideEffect(InfoEffect.MoveGroupTypeStep) + } + } + + fun focusChange(infoStep: InfoStep) { + intent { + copy(step = infoStep) + } + } + +} diff --git a/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/create/matching/GroupMatchingSettingContract.kt b/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/create/matching/GroupMatchingSettingContract.kt new file mode 100644 index 00000000..22d98c4e --- /dev/null +++ b/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/create/matching/GroupMatchingSettingContract.kt @@ -0,0 +1,25 @@ +package com.sixkids.teacher.challenge.create.matching + +import androidx.annotation.StringRes +import com.sixkids.model.MemberSimple +import com.sixkids.teacher.challenge.R +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState + +data class GroupMatchingSettingState( + val studentList: List = emptyList(), + val matchingType: MatchingType = MatchingType.FRIENDLY +) : UiState + +sealed interface GroupMatchingSettingEffect: SideEffect { + data class MoveToMatchingSuccessStep(val matchingMemberList: List, val matchingType: MatchingType): GroupMatchingSettingEffect + data class ShowSnackbar(val message: String): GroupMatchingSettingEffect +} + +enum class MatchingType( + @StringRes val textRes: Int +) { + FRIENDLY(R.string.matching_friendly_type), + UNFRIENDLY(R.string.matching_unfriendly_type), + RAND(R.string.matching_random_type) +} diff --git a/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/create/matching/GroupMatchingSettingScreen.kt b/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/create/matching/GroupMatchingSettingScreen.kt new file mode 100644 index 00000000..6cbf3bec --- /dev/null +++ b/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/create/matching/GroupMatchingSettingScreen.kt @@ -0,0 +1,190 @@ +package com.sixkids.teacher.challenge.create.matching + +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.wrapContentSize +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.sixkids.designsystem.component.button.UlbanFilledButton +import com.sixkids.designsystem.component.checkbox.TextRadioButton +import com.sixkids.designsystem.theme.Blue +import com.sixkids.designsystem.theme.Cream +import com.sixkids.designsystem.theme.Gray +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.model.MemberSimple +import com.sixkids.teacher.challenge.R +import com.sixkids.teacher.challenge.create.matching.component.MemberIcon +import com.sixkids.teacher.challenge.create.matching.component.MemberIconItem +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.extension.collectWithLifecycle + +@Composable +fun GroupMatchingSettingRoute( + modifier: Modifier = Modifier, + viewModel: GroupMatchingSettingViewModel = hiltViewModel(), + moveNextStep: () -> Unit, + onUpdateMatchingMemberList: (List) -> Unit, + onUpdateMatchingType: (MatchingType) -> Unit, + onShowSnackbar: (SnackbarToken) -> Unit +) { + + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + viewModel.sideEffect.collectWithLifecycle { + when (it) { + is GroupMatchingSettingEffect.ShowSnackbar -> { + onShowSnackbar(SnackbarToken(it.message)) + } + + is GroupMatchingSettingEffect.MoveToMatchingSuccessStep -> { + onUpdateMatchingType(it.matchingType) + onUpdateMatchingMemberList(it.matchingMemberList) + moveNextStep() + } + } + } + + LaunchedEffect(Unit) { + viewModel.initData() + } + + GroupMatchingSettingScreen( + modifier = modifier, + state = uiState, + onNextButtonClick = viewModel::moveNextStep, + removeMember = viewModel::removeStudent, + selectMatchingType = viewModel::selectMatchingType + ) +} + +@Composable +fun GroupMatchingSettingScreen( + modifier: Modifier = Modifier, + state: GroupMatchingSettingState = GroupMatchingSettingState(), + removeMember: (Long) -> Unit = {}, + selectMatchingType: (MatchingType) -> Unit = {}, + onNextButtonClick: () -> Unit = {} +) { + val radioOptions = MatchingType.entries + val (selectedOption, onOptionSelected) = remember { mutableStateOf(radioOptions[0]) } + + Column( + modifier = modifier + .fillMaxSize() + .padding(20.dp) + ) { + Text( + text = stringResource(id = R.string.matching_choice_type), + style = UlbanTypography.titleSmall + ) + Spacer(modifier = Modifier.height(20.dp)) + radioOptions.forEach { option -> + TextRadioButton( + selected = selectedOption == option, + onClick = { + selectMatchingType(option) + onOptionSelected(option) + }, + text = stringResource(id = option.textRes), + ) + Spacer(modifier = Modifier.height(4.dp)) + } + + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(id = R.string.matching_choice_student), + style = UlbanTypography.titleSmall + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + text = "${state.studentList.size}명", + style = UlbanTypography.bodyLarge.copy( + color = Gray + ) + ) + } + HorizontalDivider( + modifier = Modifier.padding(vertical = 10.dp), + color = Blue, + thickness = 2.dp + ) + // 학생 목록 + LazyVerticalGrid( + modifier = Modifier + .weight(1f), + columns = GridCells.Fixed(4), + ) { + items(state.studentList.size) { index -> + Card( + modifier = Modifier + .wrapContentSize() + .padding(bottom = 8.dp), + colors = CardDefaults.cardColors( + containerColor = Cream + ), + elevation = CardDefaults.cardElevation( + defaultElevation = 4.dp + ) + ) { + MemberIcon( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(2.dp), + memberIconItem = MemberIconItem( + member = state.studentList[index], + isActive = true, + showX = true, + ), + onRemoveClick = { + removeMember(it) + }, + ) + } + } + } + UlbanFilledButton( + modifier = Modifier + .fillMaxWidth(), + text = stringResource(id = R.string.next), + onClick = onNextButtonClick + ) + } +} + +@Preview(showBackground = true) +@Composable +fun GroupMatchingSettingScreenPreview() { + GroupMatchingSettingScreen( + state = GroupMatchingSettingState( + studentList = List(30) { + MemberSimple( + id = it.toLong(), + name = "학생 $it" + ) + } + ) + ) +} diff --git a/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/create/matching/GroupMatchingSettingViewModel.kt b/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/create/matching/GroupMatchingSettingViewModel.kt new file mode 100644 index 00000000..35ec69bb --- /dev/null +++ b/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/create/matching/GroupMatchingSettingViewModel.kt @@ -0,0 +1,57 @@ +package com.sixkids.teacher.challenge.create.matching + +import androidx.lifecycle.viewModelScope +import com.sixkids.domain.usecase.organization.GetOrganizationMemberUseCase +import com.sixkids.domain.usecase.organization.GetSelectedOrganizationIdUseCase +import com.sixkids.ui.base.BaseViewModel +import com.sixkids.ui.extension.flatMap +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class GroupMatchingSettingViewModel @Inject constructor( + private val getSelectedOrganizationIdUseCase: GetSelectedOrganizationIdUseCase, + private val getOrganizationMemberUseCase: GetOrganizationMemberUseCase, +) : BaseViewModel( + GroupMatchingSettingState() +) { + + private var orgId = 0L + fun initData() { + viewModelScope.launch { + getSelectedOrganizationIdUseCase() + .flatMap { orgId -> + this@GroupMatchingSettingViewModel.orgId = orgId.toLong() + getOrganizationMemberUseCase(orgId) + } + .onSuccess { memberList -> + intent { + copy(studentList = memberList) + } + } + } + } + + fun removeStudent(memberId: Long) { + intent { + copy(studentList = studentList.filter { it.id != memberId }) + } + } + + fun moveNextStep() { + postSideEffect( + GroupMatchingSettingEffect.MoveToMatchingSuccessStep( + matchingMemberList = uiState.value.studentList.map { it.id }, + matchingType = uiState.value.matchingType + ) + ) + } + + fun selectMatchingType(matchingType: MatchingType) { + intent { + copy(matchingType = matchingType) + } + } + +} diff --git a/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/create/matching/GroupMatchingSuccessContract.kt b/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/create/matching/GroupMatchingSuccessContract.kt new file mode 100644 index 00000000..d1b937b2 --- /dev/null +++ b/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/create/matching/GroupMatchingSuccessContract.kt @@ -0,0 +1,28 @@ +package com.sixkids.teacher.challenge.create.matching + +import com.sixkids.model.ChallengeGroup +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState + + +data class GroupMatchingSuccessState( + val isLoading: Boolean = false, + val groupList: List = emptyList(), + val orgId: Long = 0L, + val minCount: Int = 0, + val matchingType: MatchingType = MatchingType.FRIENDLY, + val members: List = emptyList() +) : UiState + +sealed interface GroupMatchingSuccessEffect : SideEffect { + data object CreateChallenge : GroupMatchingSuccessEffect + + data class ShowSnackbar(val message: String) : GroupMatchingSuccessEffect +} + +data class MatchingSource( + val orgId: Long = 0L, + val minCount: Int = 0, + val matchingType: MatchingType = MatchingType.FRIENDLY, + val members: List = emptyList() +) diff --git a/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/create/matching/GroupMatchingSuccessScreen.kt b/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/create/matching/GroupMatchingSuccessScreen.kt new file mode 100644 index 00000000..20b64290 --- /dev/null +++ b/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/create/matching/GroupMatchingSuccessScreen.kt @@ -0,0 +1,281 @@ +package com.sixkids.teacher.challenge.create.matching + +import androidx.compose.foundation.layout.Arrangement +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.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.sixkids.designsystem.component.button.UlbanFilledButton +import com.sixkids.designsystem.component.item.StudentSimpleCardItem +import com.sixkids.designsystem.theme.Blue +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.model.ChallengeGroup +import com.sixkids.model.MemberSimple +import com.sixkids.teacher.challenge.R +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.extension.collectWithLifecycle + +@Composable +fun GroupMatchingSuccessRoute( + modifier: Modifier = Modifier, + viewModel: GroupMatchingSuccessViewModel = hiltViewModel(), + updateGroupList: (List) -> Unit, + createChallenge: () -> Unit, + onGetMatchingGroupList: () -> (MatchingSource), + onShowSnackbar: (SnackbarToken) -> Unit, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.updateMatchingSource(onGetMatchingGroupList()) + viewModel.getMatchingGroupList() + } + + viewModel.sideEffect.collectWithLifecycle { + when (it) { + is GroupMatchingSuccessEffect.ShowSnackbar -> { + onShowSnackbar(SnackbarToken(it.message)) + } + + GroupMatchingSuccessEffect.CreateChallenge -> { + updateGroupList(uiState.groupList) + createChallenge() + } + } + } + + GroupMatchingSuccessScreen( + modifier = modifier, + groupMatchingSuccessState = uiState, + onNextButtonClick = viewModel::createChallenge + ) + +} + +@Composable +fun GroupMatchingSuccessScreen( + modifier: Modifier = Modifier, + groupMatchingSuccessState: GroupMatchingSuccessState = GroupMatchingSuccessState(), + onNextButtonClick: () -> Unit = {} +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(20.dp) + ) { + Text( + text = stringResource(id = R.string.matching_success), + style = UlbanTypography.titleSmall + ) + Spacer(modifier = Modifier.height(10.dp)) + Column( + modifier = Modifier + .weight(1f) + .padding(bottom = 10.dp) + .verticalScroll(rememberScrollState()) + ) { + groupMatchingSuccessState.groupList.forEachIndexed { index, group -> + Spacer(modifier = Modifier.height(14.dp)) + Text( + text = "그룹 ${index + 1}", + style = UlbanTypography.bodyMedium + ) + HorizontalDivider( + modifier = Modifier.padding(vertical = 10.dp), + color = Blue, + thickness = 2.dp + ) + // 수동 그리드 레이아웃 + Column(modifier = Modifier.fillMaxWidth()) { + val rows = group.memberList.chunked(4) // 4개의 아이템씩 묶음 + rows.forEach { rowItems -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + rowItems.forEach { member -> + StudentSimpleCardItem( + modifier = Modifier + .weight(1f) + .padding(4.dp), + name = member.name, + photo = member.photo + ) + } + // 빈 공간 채우기 (열이 4개 미만일 때) + repeat(4 - rowItems.size) { + Spacer(modifier = Modifier.weight(1f)) + } + } + Spacer(modifier = Modifier.height(6.dp)) // 행 간 간격 + } + } + } + } + UlbanFilledButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.next), + onClick = onNextButtonClick + ) + } +} + +@Preview(showBackground = true) +@Composable +fun GroupMatchingSuccessScreenPreview() { + GroupMatchingSuccessScreen( + groupMatchingSuccessState = GroupMatchingSuccessState( + groupList = listOf( + ChallengeGroup( + headCount = 2, + memberList = listOf( + MemberSimple( + name = "김철수", + photo = "https://cdn.pixabay.com/photo/2015/04/23/22/00/tree-736885__480.jpg" + ), + MemberSimple( + name = "김영희", + photo = "https://cdn.pixabay.com/photo/2015/04/23/22/00/tree-736885__480.jpg" + ), + MemberSimple( + name = "김철수", + photo = "https://cdn.pixabay.com/photo/2015/04/23/22/00/tree-736885__480.jpg" + ), + MemberSimple( + name = "김영희", + photo = "https://cdn.pixabay.com/photo/2015/04/23/22/00/tree-736885__480.jpg" + ), + MemberSimple( + name = "김영희", + photo = "https://cdn.pixabay.com/photo/2015/04/23/22/00/tree-736885__480.jpg" + ), + ) + ), + ChallengeGroup( + headCount = 2, + memberList = listOf( + MemberSimple( + name = "박철수", + photo = "https://cdn.pixabay.com/photo/2015/04/23/22/00/tree-736885__480.jpg" + ), + MemberSimple( + name = "박영희", + photo = "https://cdn.pixabay.com/photo/2015/04/23/22/00/tree-736885__480.jpg" + ), MemberSimple( + name = "박철수", + photo = "https://cdn.pixabay.com/photo/2015/04/23/22/00/tree-736885__480.jpg" + ), + MemberSimple( + name = "박영희", + photo = "https://cdn.pixabay.com/photo/2015/04/23/22/00/tree-736885__480.jpg" + ), + MemberSimple( + name = "박영희", + photo = "https://cdn.pixabay.com/photo/2015/04/23/22/00/tree-736885__480.jpg" + ), + MemberSimple( + name = "박영희", + photo = "https://cdn.pixabay.com/photo/2015/04/23/22/00/tree-736885__480.jpg" + ) + ) + ), + ChallengeGroup( + headCount = 2, + memberList = listOf( + MemberSimple( + name = "박철수", + photo = "https://cdn.pixabay.com/photo/2015/04/23/22/00/tree-736885__480.jpg" + ), + MemberSimple( + name = "박영희", + photo = "https://cdn.pixabay.com/photo/2015/04/23/22/00/tree-736885__480.jpg" + ), MemberSimple( + name = "박철수", + photo = "https://cdn.pixabay.com/photo/2015/04/23/22/00/tree-736885__480.jpg" + ), + MemberSimple( + name = "박영희", + photo = "https://cdn.pixabay.com/photo/2015/04/23/22/00/tree-736885__480.jpg" + ) + ) + ), + ChallengeGroup( + headCount = 2, + memberList = listOf( + MemberSimple( + name = "박철수", + photo = "https://cdn.pixabay.com/photo/2015/04/23/22/00/tree-736885__480.jpg" + ), + MemberSimple( + name = "박영희", + photo = "https://cdn.pixabay.com/photo/2015/04/23/22/00/tree-736885__480.jpg" + ), MemberSimple( + name = "박철수", + photo = "https://cdn.pixabay.com/photo/2015/04/23/22/00/tree-736885__480.jpg" + ), + MemberSimple( + name = "박영희", + photo = "https://cdn.pixabay.com/photo/2015/04/23/22/00/tree-736885__480.jpg" + ) + ) + ), + ChallengeGroup( + headCount = 2, + memberList = listOf( + MemberSimple( + name = "박철수", + photo = "https://cdn.pixabay.com/photo/2015/04/23/22/00/tree-736885__480.jpg" + ), + MemberSimple( + name = "박영희", + photo = "https://cdn.pixabay.com/photo/2015/04/23/22/00/tree-736885__480.jpg" + ), MemberSimple( + name = "박철수", + photo = "https://cdn.pixabay.com/photo/2015/04/23/22/00/tree-736885__480.jpg" + ), + MemberSimple( + name = "박영희", + photo = "https://cdn.pixabay.com/photo/2015/04/23/22/00/tree-736885__480.jpg" + ) + ) + ), + ChallengeGroup( + headCount = 2, + memberList = listOf( + MemberSimple( + name = "박철수", + photo = "https://cdn.pixabay.com/photo/2015/04/23/22/00/tree-736885__480.jpg" + ), + MemberSimple( + name = "박영희", + photo = "https://cdn.pixabay.com/photo/2015/04/23/22/00/tree-736885__480.jpg" + ), MemberSimple( + name = "박철수", + photo = "https://cdn.pixabay.com/photo/2015/04/23/22/00/tree-736885__480.jpg" + ), + MemberSimple( + name = "박영희", + photo = "https://cdn.pixabay.com/photo/2015/04/23/22/00/tree-736885__480.jpg" + ) + ) + ) + ) + ) + ) +} diff --git a/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/create/matching/GroupMatchingSuccessViewModel.kt b/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/create/matching/GroupMatchingSuccessViewModel.kt new file mode 100644 index 00000000..772fd0e7 --- /dev/null +++ b/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/create/matching/GroupMatchingSuccessViewModel.kt @@ -0,0 +1,50 @@ +package com.sixkids.teacher.challenge.create.matching + +import androidx.lifecycle.viewModelScope +import com.sixkids.domain.usecase.group.GetMatchingGroupUseCase +import com.sixkids.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class GroupMatchingSuccessViewModel @Inject constructor( + private val getMatchingGroupUseCase: GetMatchingGroupUseCase, +) : BaseViewModel(GroupMatchingSuccessState()) { + + fun updateMatchingSource( + matchingSource: MatchingSource + ) { + intent { + copy( + orgId = matchingSource.orgId, + minCount = matchingSource.minCount, + matchingType = matchingSource.matchingType, + members = matchingSource.members + ) + } + } + + fun getMatchingGroupList() { + viewModelScope.launch { + getMatchingGroupUseCase( + uiState.value.orgId, + uiState.value.minCount, + uiState.value.matchingType.name, + uiState.value.members + ).onSuccess { + intent { + copy(groupList = it) + } + }.onFailure { + postSideEffect(GroupMatchingSuccessEffect.ShowSnackbar("그룹 만들기 실패")) + } + } + } + + fun createChallenge() { + postSideEffect(GroupMatchingSuccessEffect.CreateChallenge) + + } + +} diff --git a/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/create/matching/component/MemberIcon.kt b/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/create/matching/component/MemberIcon.kt new file mode 100644 index 00000000..033c9c6e --- /dev/null +++ b/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/create/matching/component/MemberIcon.kt @@ -0,0 +1,122 @@ +package com.sixkids.teacher.challenge.create.matching.component + +import androidx.compose.foundation.background +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.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.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.model.MemberSimple +import com.sixkids.designsystem.R as DesignSystemR + +@Composable +fun MemberIcon( + modifier: Modifier = Modifier, + memberIconItem: MemberIconItem, + onIconClick: (MemberIconItem) -> Unit = {}, + onRemoveClick: (Long) -> Unit = {}, +) { + Card( + modifier = modifier + .wrapContentSize() + .background( + if (memberIconItem.isActive) Color.Transparent else Color.Gray, + shape = RoundedCornerShape(8.dp), + ) + .graphicsLayer { + alpha = if (memberIconItem.isActive) 1f else 0.5f + }, + colors = CardDefaults.cardColors(containerColor = Color.Transparent), + + onClick = { onIconClick(memberIconItem) } + ) { + Box(modifier.wrapContentSize()) { + + Column( + modifier = modifier.wrapContentSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Box( + modifier = modifier.wrapContentSize(), + ) { + Card { + AsyncImage( + model = memberIconItem.member.photo, + contentDescription = null, + modifier = modifier.size(48.dp), + contentScale = ContentScale.Crop + ) + } + if (memberIconItem.showX) { + Icon( + imageVector = ImageVector.vectorResource(DesignSystemR.drawable.ic_close_filled), + contentDescription = "Close icon", + tint = Color.Red, + modifier = Modifier + .align(Alignment.TopEnd) + .size(24.dp) + .clickable { + onRemoveClick(memberIconItem.member.id) + } + ) + } + } + Text( + text = memberIconItem.member.name, + style = UlbanTypography.bodyMedium, + modifier = Modifier.padding(4.dp), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + + +} + +data class MemberIconItem( + val member: MemberSimple = MemberSimple(), + val showX: Boolean = false, + val isActive: Boolean = false +) + +@Preview(showBackground = true) +@Composable +fun MemberIconPreview() { + MemberIcon( + memberIconItem = MemberIconItem( + member = MemberSimple( + id = 1, + name = "Leader", + photo = "https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png" + + ), + showX = true, + isActive = true + ), + onIconClick = {}, + onRemoveClick = {} + ) +} diff --git a/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/detail/ChallengeDetailContract.kt b/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/detail/ChallengeDetailContract.kt new file mode 100644 index 00000000..3257d873 --- /dev/null +++ b/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/detail/ChallengeDetailContract.kt @@ -0,0 +1,17 @@ +package com.sixkids.teacher.challenge.detail + +import com.sixkids.model.ChallengeDetail +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState +import kotlin.reflect.KFunction0 +import kotlin.reflect.KFunction2 + +data class ChallengeDetailState( + val isLoading: Boolean = false, + val challengeDetail: ChallengeDetail = ChallengeDetail(), +) : UiState + +sealed interface ChallengeDetailSideEffect : SideEffect { + data class HandleException(val throwable: Throwable, val retry: () -> Unit) : + ChallengeDetailSideEffect +} diff --git a/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/detail/ChallengeDetailScreen.kt b/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/detail/ChallengeDetailScreen.kt new file mode 100644 index 00000000..037f178c --- /dev/null +++ b/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/detail/ChallengeDetailScreen.kt @@ -0,0 +1,178 @@ +package com.sixkids.teacher.challenge.detail + +import androidx.compose.foundation.layout.Arrangement +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.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +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.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.sixkids.designsystem.component.appbar.UlbanDetailAppBar +import com.sixkids.designsystem.component.item.DisplayableMember +import com.sixkids.designsystem.component.item.UlbanReportItem +import com.sixkids.designsystem.theme.Red +import com.sixkids.designsystem.theme.UlbanTheme +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.model.AcceptStatus +import com.sixkids.teacher.challenge.R +import com.sixkids.ui.extension.collectWithLifecycle +import com.sixkids.ui.util.formatToMonthDayTime + + +@Composable +fun ChallengeDetailRoute( + viewModel: ChallengeDetailViewModel = hiltViewModel(), + handleException: (Throwable, () -> Unit) -> Unit +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + viewModel.sideEffect.collectWithLifecycle { + when (it) { + is ChallengeDetailSideEffect.HandleException -> handleException(it.throwable, it.retry) + } + } + + LaunchedEffect(key1 = Unit) { + viewModel.getChallengeDetail() + } + + ChallengeDetailScreen( + uiState = uiState, + approveReport = { viewModel.gradingReport(it, AcceptStatus.APPROVE) }, + refuseReport = { viewModel.gradingReport(it, AcceptStatus.REFUSE) } + ) +} + + +@Composable +fun ChallengeDetailScreen( + uiState: ChallengeDetailState = ChallengeDetailState(), + approveReport: (Long) -> Unit = {}, + refuseReport: (Long) -> Unit = {} +) { + val listState = rememberLazyListState() + val isScrolled by remember { + derivedStateOf { + listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 100 + } + } + + Column( + modifier = Modifier + .fillMaxSize() + ) { + UlbanDetailAppBar( + leftIcon = com.sixkids.designsystem.R.drawable.hifive, + title = stringResource(id = R.string.hifive_challenge), + content = uiState.challengeDetail.title, + topDescription = "${uiState.challengeDetail.startTime.formatToMonthDayTime()} ~ ${uiState.challengeDetail.endTime.formatToMonthDayTime()}", + bottomDescription = uiState.challengeDetail.content, + color = Red, + expanded = !isScrolled, + ) + Column( + modifier = Modifier + .fillMaxSize() + .padding(top = 8.dp, start = 16.dp, end = 16.dp, bottom = 16.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + modifier = Modifier + .padding(start = 4.dp) + .size(32.dp), + painter = painterResource(id = com.sixkids.designsystem.R.drawable.member), + tint = Color.Unspecified, + contentDescription = null + ) + Text( + text = stringResource( + id = R.string.challenge_report_state, + uiState.challengeDetail.teamCount, + uiState.challengeDetail.headCount + ), + style = UlbanTypography.bodyMedium.copy(fontWeight = FontWeight.Bold) + ) + } + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + if (uiState.challengeDetail.reportList.isEmpty()) { + Spacer(modifier = Modifier.weight(1f)) + Text( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + text = stringResource(id = R.string.no_challenge_history), + style = UlbanTypography.titleLarge, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.weight(1f)) + } else { + LazyColumn( + modifier = Modifier.weight(1f), + state = listState, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(uiState.challengeDetail.reportList) { report -> + UlbanReportItem( + startDate = report.startTime, + endDate = report.endTime, + file = report.file, + memberList = report.group.studentList.map { + object : DisplayableMember { + override val name: String + get() = it.name + override val photo: String + get() = it.photo + override val isLeader: Boolean + get() = it.id == report.group.leaderId + } + }, + content = report.content, + accepted = report.acceptStatus != AcceptStatus.BEFORE, + onAccept = { + approveReport(report.id) + }, + onReject = { + refuseReport(report.id) + } + ) + } + } + } + } + + } +} + +@Preview(showBackground = true) +@Composable +fun ChallengeDetailScreenPreview() { + UlbanTheme { + ChallengeDetailScreen() + } +} diff --git a/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/detail/ChallengeDetailViewModel.kt b/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/detail/ChallengeDetailViewModel.kt new file mode 100644 index 00000000..22e6d52a --- /dev/null +++ b/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/detail/ChallengeDetailViewModel.kt @@ -0,0 +1,62 @@ +package com.sixkids.teacher.challenge.detail + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.sixkids.domain.usecase.challenge.GetChallengeDetailUseCase +import com.sixkids.domain.usecase.challenge.GradingReportUseCase +import com.sixkids.model.AcceptStatus +import com.sixkids.teacher.challenge.navigation.ChallengeRoute.CHALLENGE_ID_NAME +import com.sixkids.teacher.challenge.navigation.ChallengeRoute.GROUP_ID_NAME +import com.sixkids.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ChallengeDetailViewModel @Inject constructor( + private val getChallengeDetailUseCase: GetChallengeDetailUseCase, + private val gradingReportUseCase: GradingReportUseCase, + savedStateHandle: SavedStateHandle +) : BaseViewModel( + ChallengeDetailState() +) { + private val challengeId = savedStateHandle.get(CHALLENGE_ID_NAME)!! + private val groupId = savedStateHandle.get(GROUP_ID_NAME) + + fun getChallengeDetail() { + viewModelScope.launch { + intent { copy(isLoading = true) } + getChallengeDetailUseCase(challengeId, groupId).onSuccess { + intent { copy(challengeDetail = it) } + }.onFailure { + postSideEffect(ChallengeDetailSideEffect.HandleException(it, ::getChallengeDetail)) + } + intent { copy(isLoading = false) } + } + } + + fun gradingReport(reportId: Long, acceptStatus: AcceptStatus) { + viewModelScope.launch { + intent { copy(isLoading = true) } + gradingReportUseCase(reportId, acceptStatus).onSuccess { + intent { copy(isLoading = false, challengeDetail = challengeDetail.copy( + reportList = challengeDetail.reportList.map { report -> + if (report.id == reportId) { + report.copy(acceptStatus = acceptStatus) + } else { + report + } + } + + )) } + }.onFailure { + postSideEffect(ChallengeDetailSideEffect.HandleException(it) { + gradingReport( + reportId, + acceptStatus + ) + }) + } + } + } +} diff --git a/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/history/ChallengeHistoryContract.kt b/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/history/ChallengeHistoryContract.kt new file mode 100644 index 00000000..bf0323dc --- /dev/null +++ b/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/history/ChallengeHistoryContract.kt @@ -0,0 +1,17 @@ +package com.sixkids.teacher.challenge.history + +import com.sixkids.model.RunningChallenge +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState + +data class ChallengeHistoryState( + val isLoading: Boolean = false, + val runningChallenge: RunningChallenge? = null, + val totalChallengeCount: Int = 0, +) : UiState + +sealed interface ChallengeHistoryEffect : SideEffect { + data class NavigateToChallengeDetail(val challengeId: Long, val groupId: Long? = null) : ChallengeHistoryEffect + data object NavigateToCreateChallenge : ChallengeHistoryEffect + data class HandleException(val throwable: Throwable, val retry: () -> Unit) : ChallengeHistoryEffect +} diff --git a/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/history/ChallengeHistoryScreen.kt b/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/history/ChallengeHistoryScreen.kt new file mode 100644 index 00000000..2bfb4f44 --- /dev/null +++ b/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/history/ChallengeHistoryScreen.kt @@ -0,0 +1,190 @@ +package com.sixkids.teacher.challenge.history + +import android.util.Log +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import com.sixkids.designsystem.component.appbar.UlbanDefaultAppBar +import com.sixkids.designsystem.component.appbar.UlbanDetailWithProgressAppBar +import com.sixkids.designsystem.component.item.UlbanChallengeItem +import com.sixkids.designsystem.theme.Red +import com.sixkids.designsystem.theme.UlbanTheme +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.model.Challenge +import com.sixkids.teacher.challenge.R +import com.sixkids.ui.util.formatToMonthDayTime + +@Composable +fun ChallengeRoute( + viewModel: ChallengeHistoryViewModel = hiltViewModel(), + navigateToDetail: (Long, Long?) -> Unit, + navigateToCreate: () -> Unit, + handleException: (Throwable, () -> Unit) -> Unit +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(key1 = Unit) { + viewModel.initData() + } + + LaunchedEffect(key1 = viewModel.sideEffect) { + viewModel.sideEffect.collect { sideEffect -> + when (sideEffect) { + is ChallengeHistoryEffect.NavigateToChallengeDetail -> navigateToDetail( + sideEffect.challengeId, + sideEffect.groupId + ) + + ChallengeHistoryEffect.NavigateToCreateChallenge -> navigateToCreate() + is ChallengeHistoryEffect.HandleException -> handleException( + sideEffect.throwable, + sideEffect.retry + ) + } + } + } + + ChallengeHistoryScreen( + uiState = uiState, + challengeItems = viewModel.challengeHistory?.collectAsLazyPagingItems(), + navigateToDetail = { challengeId -> + viewModel.navigateChallengeDetail(challengeId) + }, + navigateToCreate = navigateToCreate, + updateTotalCount = viewModel::updateTotalCount + ) +} + +@Composable +fun ChallengeHistoryScreen( + uiState: ChallengeHistoryState = ChallengeHistoryState(), + challengeItems: LazyPagingItems? = null, + navigateToDetail: (Long) -> Unit = {}, + navigateToCreate: () -> Unit = {}, + updateTotalCount: (Int) -> Unit = {} +) { + val listState = rememberLazyListState() + val isScrolled by remember { + derivedStateOf { + listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 100 + } + } + + Column( + modifier = Modifier + .fillMaxSize() + ) { + val currentChallenge = uiState.runningChallenge + if (currentChallenge == null) { + UlbanDefaultAppBar( + leftIcon = com.sixkids.designsystem.R.drawable.hifive, + title = stringResource(id = R.string.hifive_challenge), + content = stringResource(id = R.string.challenge_create), + color = Red, + onclick = navigateToCreate, + expanded = !isScrolled + ) + } else { + UlbanDetailWithProgressAppBar( + leftIcon = com.sixkids.designsystem.R.drawable.hifive, + title = stringResource(id = R.string.hifive_challenge), + content = currentChallenge.title, + topDescription = "${currentChallenge.startTime.formatToMonthDayTime()} ~ ${currentChallenge.endTime.formatToMonthDayTime()}", + bottomDescription = currentChallenge.content, + color = Red, + onclick = { navigateToDetail(currentChallenge.id) }, + totalCnt = currentChallenge.totalMemberCount, + successCnt = currentChallenge.doneMemberCount, + badgeCount = currentChallenge.waitingCount, + expanded = !isScrolled + ) + } + Spacer(modifier = Modifier.padding(12.dp)) + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + ) { + Text( + modifier = Modifier.padding(horizontal = 4.dp), + text = stringResource( + id = R.string.total_challenge_count, + uiState.totalChallengeCount + ), + style = UlbanTypography.bodyMedium.copy(fontWeight = FontWeight.Bold) + ) + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + + if (challengeItems == null) { + Spacer(modifier = Modifier.weight(1f)) + Text( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + text = stringResource(id = R.string.no_challenge_history), + style = UlbanTypography.titleLarge, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.weight(1f)) + } else { + LazyColumn( + state = listState, + contentPadding = PaddingValues(vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Log.d("DTEST", "ChallengeHistoryScreen: challengeItems.itemCount ${challengeItems.itemCount}") + items(challengeItems.itemCount) { index -> + challengeItems[index]?.let { challenge -> + if(index == 0) { + Log.d("DTEST", "ChallengeHistoryScreen: updateTotalCount ${challenge.totalCount}") + updateTotalCount(challenge.totalCount) + } + UlbanChallengeItem( + title = challenge.title, + description = challenge.content, + startDate = challenge.startTime, + endDate = challenge.endTime, + userCount = challenge.headCount, + onClick = { navigateToDetail(challenge.id) } + ) + } + } + } + } + } + + } +} + +@Preview(showBackground = true) +@Composable +fun MyPageDefaultScreenPreview() { + UlbanTheme { + ChallengeHistoryScreen() + } +} diff --git a/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/history/ChallengeHistoryViewModel.kt b/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/history/ChallengeHistoryViewModel.kt new file mode 100644 index 00000000..6536d545 --- /dev/null +++ b/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/history/ChallengeHistoryViewModel.kt @@ -0,0 +1,78 @@ +package com.sixkids.teacher.challenge.history + +import android.util.Log +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn +import com.sixkids.domain.usecase.challenge.GetChallengeHistoryUseCase +import com.sixkids.domain.usecase.challenge.GetRunningChallengeUseCase +import com.sixkids.domain.usecase.organization.GetSelectedOrganizationIdUseCase +import com.sixkids.model.Challenge +import com.sixkids.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ChallengeHistoryViewModel @Inject constructor( + private val getSelectedOrganizationIdUseCase: GetSelectedOrganizationIdUseCase, + private val getChallengeHistoryUseCase: GetChallengeHistoryUseCase, + private val getRunningChallengeUseCase: GetRunningChallengeUseCase +) : BaseViewModel( + ChallengeHistoryState() +) { + var challengeHistory: Flow>? = null + private var isFirstVisited: Boolean = true + + private var orgId = 0L + + fun initData() = viewModelScope.launch { + if (isFirstVisited.not()) return@launch + isFirstVisited = false + + getSelectedOrganizationIdUseCase().onSuccess { + orgId = it.toLong() + }.onFailure { + postSideEffect(ChallengeHistoryEffect.HandleException(it, ::initData)) + } + getChallengeHistory() + getRunningChallenge() + } + + fun navigateChallengeDetail(challengeId: Long) = postSideEffect( + ChallengeHistoryEffect.NavigateToChallengeDetail(challengeId) + ) + + private fun getChallengeHistory() { + viewModelScope.launch { + intent { copy(isLoading = true) } + challengeHistory = getChallengeHistoryUseCase(orgId.toInt()) + .cachedIn(viewModelScope) + intent { copy(isLoading = false) } + } + } + + private fun getRunningChallenge() { + viewModelScope.launch { + intent { copy(isLoading = true) } + getRunningChallengeUseCase(1) + .onSuccess { + intent { copy(isLoading = false, runningChallenge = it) } + }.onFailure { + postSideEffect( + ChallengeHistoryEffect.HandleException( + it, + ::getRunningChallenge + ) + ) + } + } + + } + + fun updateTotalCount(totalCount: Int) { + intent { copy(totalChallengeCount = totalCount) } + } +} diff --git a/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/navigation/ChallengeNavigation.kt b/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/navigation/ChallengeNavigation.kt new file mode 100644 index 00000000..15fda03f --- /dev/null +++ b/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/navigation/ChallengeNavigation.kt @@ -0,0 +1,119 @@ +package com.sixkids.teacher.challenge.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavType +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import com.sixkids.teacher.challenge.create.ChallengeCreateRoute +import com.sixkids.teacher.challenge.detail.ChallengeDetailRoute +import com.sixkids.teacher.challenge.history.ChallengeRoute +import com.sixkids.teacher.challenge.navigation.ChallengeRoute.CHALLENGE_ID_NAME +import com.sixkids.teacher.challenge.navigation.ChallengeRoute.GROUP_ID_NAME +import com.sixkids.teacher.challenge.navigation.ChallengeRoute.CHALLENGE_TITLE_NAME +import com.sixkids.teacher.challenge.result.ResultRoute +import com.sixkids.ui.SnackbarToken + +fun NavController.navigateChallengeHistory() { + navigate(ChallengeRoute.defaultRoute) +} + +fun NavController.navigateChallengeDetail(challengeId: Long, groupId: Long?) { + navigate(ChallengeRoute.detailRoute(challengeId, groupId)) +} + +fun NavController.navigatePopupToHistory() { + navigate(ChallengeRoute.defaultRoute) { + popUpTo(ChallengeRoute.defaultRoute) { + inclusive = true + } + } +} + +fun NavController.navigateChallengeDetail(challengeId: Long) { + navigate(ChallengeRoute.detailRoute(challengeId)) +} + +fun NavController.navigateCreateChallenge() { + navigate(ChallengeRoute.createRoute) +} + +fun NavController.navigateChallengeCreatedResult(challengeId: Long, title: String) { + navigate(ChallengeRoute.resultRoute(challengeId, title)) { + popUpTo(ChallengeRoute.defaultRoute) { + inclusive = false + } + } +} + +fun NavGraphBuilder.challengeNavGraph( + navigateChallengeDetail: (Long, Long?) -> Unit, + navigateChallengeHistory: () -> Unit, + navigateChallengeCreatedResult: (Long, String) -> Unit, + navigateCreateChallenge: () -> Unit, + navigateUp: () -> Unit, + showSnackbar: (SnackbarToken) -> Unit, + handleException: (Throwable, () -> Unit) -> Unit +) { + composable(route = ChallengeRoute.defaultRoute) { + ChallengeRoute( + navigateToDetail = { challengeId, groupId -> + navigateChallengeDetail(challengeId, groupId) + }, + navigateToCreate = navigateCreateChallenge, + handleException = handleException + ) + } + + composable( + route = ChallengeRoute.detailRoute, + arguments = listOf( + navArgument(CHALLENGE_ID_NAME) { type = NavType.LongType }, + navArgument(GROUP_ID_NAME) { + type = NavType.StringType + nullable = true + defaultValue = null + } + ) + ) { + ChallengeDetailRoute( + handleException = handleException, + ) + } + + composable(route = ChallengeRoute.createRoute) { + ChallengeCreateRoute( + onShowSnackbar = showSnackbar, + onNavigateUp = navigateUp, + onNavigateResult = { challengeId, title -> + navigateChallengeCreatedResult(challengeId, title) + }, + onHandleException = handleException + ) + } + composable( + route = ChallengeRoute.resultRoute, + arguments = listOf( + navArgument(CHALLENGE_ID_NAME) { type = NavType.IntType }, + navArgument(CHALLENGE_TITLE_NAME) { type = NavType.StringType } + ) + ) { + ResultRoute( + navigateToChallengeHistory = navigateChallengeHistory, + handleException = handleException + ) + } + +} + +object ChallengeRoute { + const val CHALLENGE_ID_NAME = "challengeId" + const val CHALLENGE_TITLE_NAME = "challengeTitle" + const val GROUP_ID_NAME = "groupId" + const val defaultRoute = "challenge-history" + const val createRoute = "challenge-create" + const val detailRoute = "challenge-detail?challengeId={$CHALLENGE_ID_NAME}&groupId={${GROUP_ID_NAME}}" + const val resultRoute = "challenge-create-result?challengeId={$CHALLENGE_ID_NAME}&title={$CHALLENGE_TITLE_NAME}" + fun detailRoute(challengeId: Long, groupId: Long? = null) = "challenge-detail?challengeId=$challengeId&groupId=$groupId" + fun resultRoute(challengeId: Long, title: String) = "challenge-create-result?challengeId=$challengeId&title=$title" +} diff --git a/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/result/ResultContract.kt b/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/result/ResultContract.kt new file mode 100644 index 00000000..d825e5bf --- /dev/null +++ b/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/result/ResultContract.kt @@ -0,0 +1,21 @@ +package com.sixkids.teacher.challenge.result + +import com.sixkids.model.Challenge +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState + +data class ResultState( + val showResultDialog: Boolean = false, + val challenge: Challenge = Challenge() +) : UiState + + +sealed interface ResultEffect : SideEffect { + data object ShowResultDialog : ResultEffect + data object NavigateToChallengeHistory : ResultEffect + + data class HandleException( + val throwable: Throwable, val retry: () -> Unit + ) : ResultEffect + +} diff --git a/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/result/ResultScreen.kt b/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/result/ResultScreen.kt new file mode 100644 index 00000000..2bb1ccd1 --- /dev/null +++ b/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/result/ResultScreen.kt @@ -0,0 +1,127 @@ +package com.sixkids.teacher.challenge.result + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.sixkids.designsystem.component.card.UlbanMissionCard +import com.sixkids.designsystem.theme.UlbanTheme +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.teacher.challenge.R +import com.sixkids.teacher.challenge.result.component.ChallengeDialog +import com.sixkids.ui.extension.collectWithLifecycle + +@Composable +fun ResultRoute( + viewModel: ResultViewModel = hiltViewModel(), + navigateToChallengeHistory: () -> Unit, + handleException: (Throwable, () -> Unit) -> Unit +) { + val uiState = viewModel.uiState.collectAsStateWithLifecycle().value + + var showDialog by remember { + mutableStateOf(false) + } + + viewModel.sideEffect.collectWithLifecycle { + when (it) { + ResultEffect.ShowResultDialog -> { + showDialog = true + } + is ResultEffect.HandleException -> handleException(it.throwable, it.retry) + ResultEffect.NavigateToChallengeHistory -> navigateToChallengeHistory() + } + } + + ResultScreen( + uiState = uiState, + onCardClick = viewModel::getChallengeInfo, + onClickConfirm = viewModel::navigateToChallengeHistory + ) + + with(uiState.challenge) { + if (showDialog) { + ChallengeDialog( + title = title, + content = content, + startTime = startTime, + endTime = endTime, + point = reward, + onConfirmClick = { + showDialog = false + } + ) + } + } +} + + +@Composable +fun ResultScreen( + paddingValues: PaddingValues = PaddingValues(32.dp), + uiState: ResultState = ResultState(), + onCardClick: () -> Unit = {}, + onClickConfirm: () -> Unit = {} +) { + BackHandler { + onClickConfirm() + } + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(id = R.string.new_challenge_created), + style = UlbanTypography.titleSmall, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.size(16.dp)) + UlbanMissionCard( + title = uiState.challenge.title, + subTitle = stringResource(R.string.detail_info), + onClick = onCardClick + ) + Image( + modifier = Modifier + .size(160.dp) + .clickable { + onClickConfirm() + }, + painter = painterResource(id = R.drawable.challenge_created_success), + contentDescription = "challenge success" + ) + } +} + + +@Preview(showBackground = true) +@Composable +fun PreviewResultContent() { + UlbanTheme { + ResultScreen() + } +} diff --git a/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/result/ResultViewModel.kt b/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/result/ResultViewModel.kt new file mode 100644 index 00000000..a55dcd7e --- /dev/null +++ b/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/result/ResultViewModel.kt @@ -0,0 +1,49 @@ +package com.sixkids.teacher.challenge.result + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.sixkids.domain.usecase.challenge.GetChallengeSimpleUseCase +import com.sixkids.model.Challenge +import com.sixkids.teacher.challenge.navigation.ChallengeRoute +import com.sixkids.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ResultViewModel @Inject constructor( + private val getChallengeSimpleUseCase: GetChallengeSimpleUseCase, + savedStateHandle: SavedStateHandle +) : BaseViewModel( + ResultState( + challenge = Challenge(title = savedStateHandle.get(ChallengeRoute.CHALLENGE_TITLE_NAME)!!) + ) +) { + + private val challengeId = savedStateHandle.get(ChallengeRoute.CHALLENGE_ID_NAME)!! + fun getChallengeInfo() { + if(uiState.value.challenge.id == 0L) { + viewModelScope.launch { + getChallengeSimpleUseCase(challengeId) + .onSuccess { + intent { + copy(challenge = it) + } + postSideEffect(ResultEffect.ShowResultDialog) + } + .onFailure { + postSideEffect(ResultEffect.HandleException(it) { + getChallengeInfo() + }) + } + } + }else{ + postSideEffect(ResultEffect.ShowResultDialog) + } + } + + fun navigateToChallengeHistory() { + postSideEffect(ResultEffect.NavigateToChallengeHistory) + } + +} diff --git a/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/result/component/ChallengeDialog.kt b/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/result/component/ChallengeDialog.kt new file mode 100644 index 00000000..cd9b0718 --- /dev/null +++ b/android/feature/teacher/challenge/src/main/java/com/sixkids/teacher/challenge/result/component/ChallengeDialog.kt @@ -0,0 +1,119 @@ +package com.sixkids.teacher.challenge.result.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.stringResource +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 com.sixkids.designsystem.component.dialog.UlbanBasicDialog +import com.sixkids.designsystem.theme.Gray +import com.sixkids.designsystem.theme.UlbanTheme +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.teacher.challenge.R +import com.sixkids.ui.util.formatPoint +import com.sixkids.ui.util.formatToMonthDayTimeKorean +import java.time.LocalDateTime + +@Composable +fun ChallengeDialog( + title: String, + content: String, + startTime: LocalDateTime, + endTime: LocalDateTime, + point: Int, + onConfirmClick: () -> Unit = {} +) { + UlbanBasicDialog( + modifier = Modifier.padding(16.dp), + onDismiss = onConfirmClick + ) { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + ChallengeBody( + title = stringResource(id = R.string.title), + content = title + ) + ChallengeBody( + title = stringResource(R.string.content), + content = content + ) + ChallengeBody( + title = stringResource(R.string.start_time), + content = startTime.formatToMonthDayTimeKorean() + ) + ChallengeBody( + title = stringResource(R.string.end_time), + content = endTime.formatToMonthDayTimeKorean() + ) + ChallengeBody( + title = stringResource(R.string.point), + content = point.formatPoint() + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + horizontalArrangement = Arrangement.End + ) { + TextButton(onClick = onConfirmClick) { + Text( + text = stringResource(R.string.confirm), + style = UlbanTypography.titleSmall, + color = Color.Black, + textAlign = TextAlign.End + ) + } + } + } +} + + +@Composable +fun ChallengeBody( + title: String, + content: String, +) { + Column( + modifier = Modifier, + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = title, + style = UlbanTypography.titleSmall.copy(fontSize = 12.sp), + color = Gray + ) + Text( + text = content, + style = UlbanTypography.titleSmall + ) + } +} + +@Preview(showBackground = true) +@Composable +fun PreviewResultDialogContent() { + UlbanTheme { + ChallengeDialog( + title = "4월 22일 깜짝 미션", + content = "문화의 날을 맞아 우리반 친구들 3명 이상 모여 영화를 관람해봐요~", + startTime = LocalDateTime.now(), + endTime = LocalDateTime.now(), + point = 1000 + ) + } +} diff --git a/android/feature/teacher/challenge/src/main/res/drawable/challenge_created_success.png b/android/feature/teacher/challenge/src/main/res/drawable/challenge_created_success.png new file mode 100644 index 00000000..fd330b99 Binary files /dev/null and b/android/feature/teacher/challenge/src/main/res/drawable/challenge_created_success.png differ diff --git a/android/feature/teacher/challenge/src/main/res/values/strings.xml b/android/feature/teacher/challenge/src/main/res/values/strings.xml new file mode 100644 index 00000000..45c6912f --- /dev/null +++ b/android/feature/teacher/challenge/src/main/res/values/strings.xml @@ -0,0 +1,36 @@ + + + 함께 달리기 + 새로운\n함께 달리기\n만들기 + 지금까지 %d번 함께 달리기를 진행했어요 + 기록이 없어요 + %d개 팀, %d명 학생이 참여했어요. + 포인트를 입력해 주세요 + 점수를 입력하세요 + 내용을 입력해 주세요 + 제목을 입력해 주세요 + 모든 항목을 입력해주세요. + 다음 + 확인 + 언제 시작할까요? + 언제 종료할까요? + 그룹 최소 인원을 설정해 주세요 + 그룹 유형을 선택해 주세요 + 학생들이 자율적으로 선택할게요 + 그룹을 지정할게요 + 새로운\n함께 달리기가 만들어졌습니다. + 상세 정보 + 제목 + 내용 + 시작 시간 + 종료 시간 + 포인트 + 종료 시간이 시작 시간보다 빠릅니다. + 오늘 이전의 날짜로 시작할 수 없습니다. + 매칭 유형을 선택해 주세요 + 랜덤으로 매칭할게요 + 친한 친구들끼리 매칭할게요 + 친하지 않은 친구들끼리 매칭할게요 + 포함할 학생들을 선택해 주세요 + 그룹이 매칭됐습니다 + diff --git a/android/app/src/test/java/com/sixkids/ulban/ExampleUnitTest.kt b/android/feature/teacher/challenge/src/test/java/com/sixkids/challenge/ExampleUnitTest.kt similarity index 91% rename from android/app/src/test/java/com/sixkids/ulban/ExampleUnitTest.kt rename to android/feature/teacher/challenge/src/test/java/com/sixkids/challenge/ExampleUnitTest.kt index b16dcc7b..f52604a2 100644 --- a/android/app/src/test/java/com/sixkids/ulban/ExampleUnitTest.kt +++ b/android/feature/teacher/challenge/src/test/java/com/sixkids/challenge/ExampleUnitTest.kt @@ -1,4 +1,4 @@ -package com.sixkids.ulban +package com.sixkids.challenge import org.junit.Test diff --git a/android/feature/teacher/home/build.gradle.kts b/android/feature/teacher/home/build.gradle.kts index 9b4ead16..f8c0ca4c 100644 --- a/android/feature/teacher/home/build.gradle.kts +++ b/android/feature/teacher/home/build.gradle.kts @@ -8,4 +8,5 @@ android { dependencies { implementation(projects.core.designsystem) + implementation(projects.feature.teacher.challenge) } diff --git a/android/feature/teacher/home/src/main/java/com/sixkids/teacher/home/component/TeacherInfo.kt b/android/feature/teacher/home/src/main/java/com/sixkids/teacher/home/component/TeacherInfo.kt index ef865685..5d85d276 100644 --- a/android/feature/teacher/home/src/main/java/com/sixkids/teacher/home/component/TeacherInfo.kt +++ b/android/feature/teacher/home/src/main/java/com/sixkids/teacher/home/component/TeacherInfo.kt @@ -4,9 +4,11 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -20,23 +22,28 @@ 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 coil.compose.AsyncImage import com.sixkids.designsystem.R +import com.sixkids.designsystem.theme.UlbanTypography //preview @Preview(showBackground = true) @Composable fun TeacherInfoCardPreview() { TeacherInfo( - teacherName = "홍유준" + teacherName = "홍유준", + organizationName = "인동 초등학교 1학년 1반" ) } @Composable fun TeacherInfo( modifier: Modifier = Modifier, - teacherName: String + organizationName: String = "", + teacherName: String, + teacherImageUrl: String = "", ) { - val height = 120.dp + val height = 80.dp Card( modifier = modifier @@ -47,20 +54,26 @@ fun TeacherInfo( ) ) { Row { - Image( - painter = painterResource(id = R.drawable.teacher_woman), - contentDescription = null, - modifier = Modifier.size(height), - contentScale = ContentScale.FillBounds - ) + Card { + AsyncImage( + model = teacherImageUrl, + contentDescription = null, + error = painterResource(id = R.drawable.teacher_woman), + modifier = Modifier.size(height), + contentScale = ContentScale.Crop + ) + } + Spacer(modifier = Modifier.width(16.dp)) Column( modifier = Modifier.height(height), verticalArrangement = Arrangement.Center ) { + Text(text = organizationName, style = UlbanTypography.titleSmall) + + Spacer(modifier = Modifier.height(8.dp)) Text( - text = "$teacherName 선생님\n환영합니다.", - fontWeight = FontWeight.Bold, - fontSize = 24.sp + text = "$teacherName 선생님", + style = UlbanTypography.titleMedium ) } } diff --git a/android/feature/teacher/home/src/main/java/com/sixkids/teacher/home/main/HomeMainContract.kt b/android/feature/teacher/home/src/main/java/com/sixkids/teacher/home/main/HomeMainContract.kt index 464203d7..0a5bc160 100644 --- a/android/feature/teacher/home/src/main/java/com/sixkids/teacher/home/main/HomeMainContract.kt +++ b/android/feature/teacher/home/src/main/java/com/sixkids/teacher/home/main/HomeMainContract.kt @@ -2,14 +2,16 @@ package com.sixkids.teacher.home.main import com.sixkids.designsystem.theme.component.card.RunningRelayState import com.sixkids.designsystem.theme.component.card.RunningTogetherState +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState -sealed interface HomeMainEffect{ +sealed interface HomeMainEffect: SideEffect{ data object NavigateToRanking : HomeMainEffect } data class HomeMainState( val isLoading: Boolean = false, + val classString: String = "", val teacherName: String = "", - val runningTogetherState: RunningTogetherState? = null, - val runningRelayState: RunningRelayState? = null, -) \ No newline at end of file + val teacherImageUrl: String = "", +): UiState \ No newline at end of file diff --git a/android/feature/teacher/home/src/main/java/com/sixkids/teacher/home/main/HomeMainScreen.kt b/android/feature/teacher/home/src/main/java/com/sixkids/teacher/home/main/HomeMainScreen.kt index 8968d928..02a5717d 100644 --- a/android/feature/teacher/home/src/main/java/com/sixkids/teacher/home/main/HomeMainScreen.kt +++ b/android/feature/teacher/home/src/main/java/com/sixkids/teacher/home/main/HomeMainScreen.kt @@ -1,37 +1,82 @@ package com.sixkids.teacher.home.main +import androidx.compose.foundation.ScrollState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio 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.verticalScroll +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue 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.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.sixkids.designsystem.R import com.sixkids.designsystem.theme.Cream +import com.sixkids.designsystem.theme.Orange +import com.sixkids.designsystem.theme.OrangeDark +import com.sixkids.designsystem.theme.OrangeText +import com.sixkids.designsystem.theme.Purple +import com.sixkids.designsystem.theme.PurpleText import com.sixkids.designsystem.theme.Red +import com.sixkids.designsystem.theme.RedDark +import com.sixkids.designsystem.theme.RedText import com.sixkids.designsystem.theme.UlbanTheme +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.designsystem.theme.Yellow +import com.sixkids.designsystem.theme.YellowText import com.sixkids.designsystem.theme.component.card.ContentAligment import com.sixkids.designsystem.theme.component.card.ContentCard -import com.sixkids.designsystem.theme.component.card.RankCard +import com.sixkids.designsystem.theme.component.card.ContentVerticalCard import com.sixkids.teacher.home.component.TeacherInfo +import com.sixkids.ui.extension.collectWithLifecycle @Composable fun HomeMainRoute( + viewModel: HomeMainViewModel = hiltViewModel(), padding: PaddingValues, - navigateToRank: () -> Unit + navigateToRank: () -> Unit, + navigateToChallenge: () -> Unit, + navigateToRelay: () -> Unit, + navigateToQuiz: () -> Unit ) { + + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + viewModel.sideEffect.collectWithLifecycle { + when (it) { + is HomeMainEffect.NavigateToRanking -> navigateToRank() + } + } + + + LaunchedEffect(Unit) { + viewModel.loadUserInfo() + viewModel.loadSelectedOrganizationName() + } + Box( modifier = Modifier .padding(padding) .fillMaxSize() ) { - HomeMainScreen(navigateToRank = navigateToRank) + HomeMainScreen( + homeMainState = uiState, + navigateToRank = navigateToRank, + navigateToChallenge = navigateToChallenge, + navigateToRelay = navigateToRelay + ) } } @@ -39,36 +84,69 @@ fun HomeMainRoute( fun HomeMainScreen( modifier: Modifier = Modifier, homeMainState: HomeMainState = HomeMainState(), - navigateToRank: () -> Unit = {} + navigateToRank: () -> Unit = {}, + navigateToChallenge: () -> Unit = {}, + navigateToRelay: () -> Unit = {} ) { Column( modifier = modifier .fillMaxSize() - .padding(start = 20.dp, end = 20.dp, top = 20.dp), + .padding(start = 20.dp, end = 20.dp, top = 20.dp) + .verticalScroll(ScrollState(0)), ) { - TeacherInfo(teacherName = "홍유준") - Spacer(modifier = Modifier.height(20.dp)) + + Spacer(modifier = Modifier.height(10.dp)) + TeacherInfo( + teacherName = homeMainState.teacherName, + teacherImageUrl = homeMainState.teacherImageUrl, + organizationName = homeMainState.classString + ) + Spacer(modifier = Modifier.weight(1f)) ContentCard( modifier = Modifier.fillMaxWidth(), contentAligment = ContentAligment.ImageEnd_TextStart, - cardColor = Cream, + cardColor = Orange, + textColor = OrangeText, contentName = "이어 달리기", contentImageId = R.drawable.relay, - runningState = homeMainState.runningRelayState + onclick = navigateToRelay ) Spacer(modifier = Modifier.height(20.dp)) ContentCard( modifier = Modifier.fillMaxWidth(), contentAligment = ContentAligment.ImageStart_TextEnd, cardColor = Red, + textColor = RedText, contentName = "함께 달리기", contentImageId = R.drawable.hifive, - runningState = homeMainState.runningTogetherState - ) - RankCard( - modifier = Modifier.padding(top = 20.dp, bottom = 20.dp), - onClick = navigateToRank + onclick = navigateToChallenge ) + Row { + ContentVerticalCard( + cardModifier = Modifier + .padding(top = 20.dp, end = 10.dp, bottom = 20.dp) + .weight(1f) + .aspectRatio(1f), + cardColor = Yellow, + textColor = YellowText, + imageDrawable = R.drawable.rank, + text = "랭킹", + onClick = navigateToRank + ) + ContentVerticalCard( + cardModifier = Modifier + .padding(top = 20.dp, start = 10.dp, bottom = 20.dp) + .weight(1f) + .aspectRatio(1f), + cardColor = Purple, + textColor = PurpleText, + imageDrawable = R.drawable.quiz, + text = "퀴즈", + onClick = { } + ) + } + + Spacer(modifier = Modifier.weight(1f)) } } @@ -77,6 +155,11 @@ fun HomeMainScreen( @Preview(showBackground = true) fun HomeMainScreenPreview() { UlbanTheme { - HomeMainScreen() + HomeMainScreen( + homeMainState = HomeMainState( + classString = "구미 초등학교 1학년 1반", + teacherName = "홍유준" + ) + ) } -} \ No newline at end of file +} diff --git a/android/feature/teacher/home/src/main/java/com/sixkids/teacher/home/main/HomeMainViewModel.kt b/android/feature/teacher/home/src/main/java/com/sixkids/teacher/home/main/HomeMainViewModel.kt new file mode 100644 index 00000000..fd61c3e3 --- /dev/null +++ b/android/feature/teacher/home/src/main/java/com/sixkids/teacher/home/main/HomeMainViewModel.kt @@ -0,0 +1,40 @@ +package com.sixkids.teacher.home.main + +import androidx.lifecycle.viewModelScope +import com.sixkids.domain.usecase.organization.LoadSelectedOrganizationNameUseCase +import com.sixkids.domain.usecase.user.LoadUserInfoUseCase +import com.sixkids.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class HomeMainViewModel @Inject constructor( + private val loadUserInfoUseCase: LoadUserInfoUseCase, + private val loadSelectedOrganizationNameUseCase: LoadSelectedOrganizationNameUseCase +): BaseViewModel(HomeMainState()){ + fun loadUserInfo(){ + viewModelScope.launch { + loadUserInfoUseCase().onSuccess { + intent { + copy( + teacherName = it.name, + teacherImageUrl = it.photo + ) + } + } + } + } + + fun loadSelectedOrganizationName(){ + viewModelScope.launch { + loadSelectedOrganizationNameUseCase().onSuccess { + intent { + copy( + classString = it.replace("\n"," ") + ) + } + } + } + } +} \ No newline at end of file diff --git a/android/feature/teacher/home/src/main/java/com/sixkids/teacher/home/navigation/HomeNavigation.kt b/android/feature/teacher/home/src/main/java/com/sixkids/teacher/home/navigation/HomeNavigation.kt index ae1fc599..dae87739 100644 --- a/android/feature/teacher/home/src/main/java/com/sixkids/teacher/home/navigation/HomeNavigation.kt +++ b/android/feature/teacher/home/src/main/java/com/sixkids/teacher/home/navigation/HomeNavigation.kt @@ -7,9 +7,10 @@ import androidx.navigation.NavOptions import androidx.navigation.compose.composable import com.sixkids.teacher.home.main.HomeMainRoute import com.sixkids.teacher.home.rank.RankRoute +import com.sixkids.ui.SnackbarToken fun NavController.navigateHome(navOptions: NavOptions) { - navigate(HomeRoute.defaultRoute,navOptions) + navigate(HomeRoute.defaultRoute, navOptions) } fun NavController.navigateRank() { @@ -18,20 +19,31 @@ fun NavController.navigateRank() { fun NavGraphBuilder.homeNavGraph( padding: PaddingValues, - navigateToRank: () -> Unit + onShowSnackBar: (SnackbarToken) -> Unit, + navigateToRank: () -> Unit, + navigateToChallenge: () -> Unit, + navigateToRelay: () -> Unit, + navigateToQuiz: () -> Unit ) { - composable(route = HomeRoute.defaultRoute){ + composable(route = HomeRoute.defaultRoute) { HomeMainRoute( - padding, - navigateToRank + padding = padding, + navigateToRank = navigateToRank, + navigateToChallenge = navigateToChallenge, + navigateToRelay = navigateToRelay, + navigateToQuiz = navigateToQuiz ) } - composable(route = HomeRoute.rankRoute){ - RankRoute(padding) + composable(route = HomeRoute.rankRoute) { + RankRoute( + padding = padding, + onShowSnackBar = onShowSnackBar + ) } } -object HomeRoute{ + +object HomeRoute { const val defaultRoute = "home" const val rankRoute = "rank" -} \ No newline at end of file +} diff --git a/android/feature/teacher/home/src/main/java/com/sixkids/teacher/home/rank/RankContract.kt b/android/feature/teacher/home/src/main/java/com/sixkids/teacher/home/rank/RankContract.kt new file mode 100644 index 00000000..05234d45 --- /dev/null +++ b/android/feature/teacher/home/src/main/java/com/sixkids/teacher/home/rank/RankContract.kt @@ -0,0 +1,15 @@ +package com.sixkids.teacher.home.rank + +import com.sixkids.model.MemberRankItem +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState + +sealed interface RankEffect : SideEffect { + data class onShowSnackBar(val message: String): RankEffect +} + +data class RankState( + val isLoading: Boolean = false, + val classString: String = "", + val rankList: List = emptyList(), +): UiState diff --git a/android/feature/teacher/home/src/main/java/com/sixkids/teacher/home/rank/RankScreen.kt b/android/feature/teacher/home/src/main/java/com/sixkids/teacher/home/rank/RankScreen.kt index 2828de84..18ed9e6d 100644 --- a/android/feature/teacher/home/src/main/java/com/sixkids/teacher/home/rank/RankScreen.kt +++ b/android/feature/teacher/home/src/main/java/com/sixkids/teacher/home/rank/RankScreen.kt @@ -5,23 +5,65 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Text +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.sixkids.designsystem.component.appbar.UlbanDefaultAppBar +import com.sixkids.designsystem.component.appbar.UlbanDetailAppBar +import com.sixkids.designsystem.component.screen.LoadingScreen +import com.sixkids.designsystem.theme.Gray +import com.sixkids.designsystem.theme.Yellow +import com.sixkids.model.MemberRankItem +import com.sixkids.teacher.home.R +import com.sixkids.teacher.home.rank.component.RankItem +import com.sixkids.teacher.home.rank.component.RankViewModel +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.extension.collectWithLifecycle +import com.sixkids.designsystem.R as UlbanRes @Composable fun RankRoute( - padding: PaddingValues + viewModel: RankViewModel = hiltViewModel(), + padding: PaddingValues, + onShowSnackBar: (SnackbarToken) -> Unit ) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + viewModel.sideEffect.collectWithLifecycle { + when (it) { + is RankEffect.onShowSnackBar -> onShowSnackBar(SnackbarToken(it.message)) + } + } + + LaunchedEffect(Unit) { + viewModel.getOrganizationName() + viewModel.getClassRank() + } + Box( modifier = Modifier .padding(padding) .fillMaxSize() ) { - RankScreen() + RankScreen( + rankState = uiState + ) + if (uiState.isLoading) { + LoadingScreen() + } } } @@ -29,13 +71,46 @@ fun RankRoute( @Composable fun RankScreen( modifier: Modifier = Modifier, + rankState: RankState = RankState() ) { + val listState = rememberLazyListState() + val isScrolled by remember { + derivedStateOf { + listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 100 + } + } Column( modifier = modifier .fillMaxSize() - .padding(start = 20.dp, end = 20.dp, top = 20.dp), ) { - Text(text = "Rank Screen") + UlbanDefaultAppBar( + leftIcon = UlbanRes.drawable.rank, + title = stringResource(id = R.string.teacher_home_rank), + content = stringResource(id = R.string.teacher_home_rank), + body = rankState.classString.replace("\n", " "), + color = Yellow, + expanded = !isScrolled + ) + LazyColumn( + modifier = Modifier + .padding(16.dp), + state = listState + ) { + items(rankState.rankList.size) { index -> + RankItem( + rank = rankState.rankList[index].rank, + name = rankState.rankList[index].name, + exp = rankState.rankList[index].exp + ) + if (index != rankState.rankList.size - 1) { + HorizontalDivider( + modifier = Modifier.padding(vertical = 4.dp), + color = Gray, + thickness = 2.dp + ) + } + } + } } } @@ -43,5 +118,61 @@ fun RankScreen( @Preview(showBackground = true) @Composable fun RankScreenPreview() { - RankScreen() + RankScreen( + rankState = RankState( + classString = "구미 초등학교 1학년 1반", + rankList = listOf( + MemberRankItem( + rank = 1, + name = "김철수", + exp = 100 + ), + MemberRankItem( + rank = 2, + name = "박영희", + exp = 90 + ), + MemberRankItem( + rank = 3, + name = "이영수", + exp = 80 + ), + MemberRankItem( + rank = 4, + name = "최영희", + exp = 70 + ), + MemberRankItem( + rank = 5, + name = "홍길동", + exp = 60 + ), + MemberRankItem( + rank = 6, + name = "김철수", + exp = 50 + ), + MemberRankItem( + rank = 7, + name = "박영희", + exp = 40 + ), + MemberRankItem( + rank = 8, + name = "이영수", + exp = 30 + ), + MemberRankItem( + rank = 9, + name = "최영희", + exp = 20 + ), + MemberRankItem( + rank = 10, + name = "홍길동", + exp = 10 + ) + ) + ) + ) } \ No newline at end of file diff --git a/android/feature/teacher/home/src/main/java/com/sixkids/teacher/home/rank/component/RankItem.kt b/android/feature/teacher/home/src/main/java/com/sixkids/teacher/home/rank/component/RankItem.kt new file mode 100644 index 00000000..bb9d6f78 --- /dev/null +++ b/android/feature/teacher/home/src/main/java/com/sixkids/teacher/home/rank/component/RankItem.kt @@ -0,0 +1,93 @@ +package com.sixkids.teacher.home.rank.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.AbsoluteAlignment +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.designsystem.R as UlbanRes + +@Composable +fun RankItem( + modifier: Modifier = Modifier, + rank: Int = 0, + name: String = "이름", + exp: Int = 0, +) { + val rankHeight = 40 + Box( + modifier = modifier.fillMaxWidth().padding(vertical = 8.dp), + ) { + // Rank + when (rank) { + 1, 2, 3 -> { + Image( + modifier = Modifier + .height(rankHeight.dp) + .aspectRatio(1f) + .align(AbsoluteAlignment.CenterLeft), + painter = painterResource( + id = when (rank) { + 1 -> UlbanRes.drawable.rank_first + 2 -> UlbanRes.drawable.rank_second + 3 -> UlbanRes.drawable.rank_third + else -> UlbanRes.drawable.rank_third + }, + ), + contentDescription = null + ) + } + + else -> { + Box( + modifier = Modifier + .height(rankHeight.dp) + .align(Alignment.CenterStart), + ){ + Text( + modifier = Modifier.align(Alignment.Center), + text = "${rank}등", + style = UlbanTypography.titleMedium, + textAlign = TextAlign.Center, + ) + } + } + } + // Name + Text( + modifier = Modifier.align(Alignment.Center), + text = name, + style = UlbanTypography.titleMedium, + maxLines = 1 + ) + // Exp + Text( + modifier = Modifier.align(Alignment.CenterEnd), + text = "${exp}점", + style = UlbanTypography.titleSmall, + maxLines = 1, + + ) + } +} + +@Preview(showBackground = true) +@Composable +fun RankItemPreview() { + RankItem( + rank = 4 + ) +} \ No newline at end of file diff --git a/android/feature/teacher/home/src/main/java/com/sixkids/teacher/home/rank/component/RankViewModel.kt b/android/feature/teacher/home/src/main/java/com/sixkids/teacher/home/rank/component/RankViewModel.kt new file mode 100644 index 00000000..b80db6e9 --- /dev/null +++ b/android/feature/teacher/home/src/main/java/com/sixkids/teacher/home/rank/component/RankViewModel.kt @@ -0,0 +1,53 @@ +package com.sixkids.teacher.home.rank.component + +import androidx.lifecycle.viewModelScope +import com.sixkids.domain.usecase.organization.GetClassRankUseCase +import com.sixkids.domain.usecase.organization.GetSelectedOrganizationIdUseCase +import com.sixkids.domain.usecase.organization.LoadSelectedOrganizationNameUseCase +import com.sixkids.teacher.home.rank.RankEffect +import com.sixkids.teacher.home.rank.RankState +import com.sixkids.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class RankViewModel @Inject constructor( + private val getSelectedOrganizationIdUseCase: GetSelectedOrganizationIdUseCase, + private val loadSelectedOrganizationNameUseCase: LoadSelectedOrganizationNameUseCase, + private val getClassRankUseCase: GetClassRankUseCase +): BaseViewModel(RankState()){ + private var organizationId: Int? = null + + private suspend fun getOrganizationId() { + organizationId = getSelectedOrganizationIdUseCase().getOrNull() + } + + fun getOrganizationName() { + viewModelScope.launch { + loadSelectedOrganizationNameUseCase().onSuccess { + intent { copy(classString = it) } + } + } + } + + fun getClassRank() { + viewModelScope.launch { + if (organizationId == null) { + getOrganizationId() + } + + if (organizationId != null) { + val result = getClassRankUseCase(organizationId!!) + result.onSuccess { + intent { copy(rankList = it) } + } + result.onFailure { + postSideEffect(RankEffect.onShowSnackBar(it.message ?: "학급 랭킹 불러오기에 실패했습니다 ;(")) + } + } else { + postSideEffect(RankEffect.onShowSnackBar("학급 정보를 불러오는데 실패했습니다 ;(")) + } + } + } +} \ No newline at end of file diff --git a/android/feature/teacher/home/src/main/res/values/strings.xml b/android/feature/teacher/home/src/main/res/values/strings.xml new file mode 100644 index 00000000..1e5f7f6f --- /dev/null +++ b/android/feature/teacher/home/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + 랭킹 + \ No newline at end of file diff --git a/android/feature/teacher/main/.gitignore b/android/feature/teacher/main/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/android/feature/teacher/main/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/android/feature/teacher/main/build.gradle.kts b/android/feature/teacher/main/build.gradle.kts new file mode 100644 index 00000000..360ed210 --- /dev/null +++ b/android/feature/teacher/main/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + alias(libs.plugins.sixkids.android.feature.compose) +} + +android { + namespace = "com.sixkids.teacher.main" +} + +dependencies { + implementation(libs.accompanist.pager) + implementation(platform(libs.firebase.bom)) + implementation(libs.bundles.firebase) +} diff --git a/android/feature/teacher/main/consumer-rules.pro b/android/feature/teacher/main/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/android/feature/teacher/main/proguard-rules.pro b/android/feature/teacher/main/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/android/feature/teacher/main/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/android/feature/teacher/main/src/androidTest/java/com/sixkids/teacher/main/ExampleInstrumentedTest.kt b/android/feature/teacher/main/src/androidTest/java/com/sixkids/teacher/main/ExampleInstrumentedTest.kt new file mode 100644 index 00000000..16ba83c6 --- /dev/null +++ b/android/feature/teacher/main/src/androidTest/java/com/sixkids/teacher/main/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.sixkids.teacher.main + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.sixkids.teacher.main.test", appContext.packageName) + } +} \ No newline at end of file diff --git a/android/feature/teacher/main/src/main/AndroidManifest.xml b/android/feature/teacher/main/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a5918e68 --- /dev/null +++ b/android/feature/teacher/main/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/android/feature/teacher/main/src/main/java/com/sixkids/teacher/main/navigation/TeacherMainNavigation.kt b/android/feature/teacher/main/src/main/java/com/sixkids/teacher/main/navigation/TeacherMainNavigation.kt new file mode 100644 index 00000000..954dcd53 --- /dev/null +++ b/android/feature/teacher/main/src/main/java/com/sixkids/teacher/main/navigation/TeacherMainNavigation.kt @@ -0,0 +1,63 @@ +package com.sixkids.teacher.main.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.sixkids.teacher.main.neworganization.NewOrganizationRoute +import com.sixkids.teacher.main.organization.OrganizationListRoute +import com.sixkids.teacher.main.profile.TeacherProfileRoute +import com.sixkids.ui.SnackbarToken + +fun NavController.navigateTeacherOrganizationList() { + navigate(TeacherMainRoute.defaultRoute) +} + +fun NavController.navigateNewOrganization() { + navigate(TeacherMainRoute.newOrganizationRoute) +} + +fun NavController.navigateProfile() { + navigate(TeacherMainRoute.profileRoute) +} + +fun NavGraphBuilder.teacherOrganizationListNavGraph( + navigateToNewOrganization: () -> Unit, + navigateToProfile: () -> Unit, + navigateToHome: () -> Unit, + navigateToSignIn: () -> Unit, + onShowSnackBar: (SnackbarToken) -> Unit, + onBackClick: () -> Unit +) { + composable(route = TeacherMainRoute.defaultRoute) { + OrganizationListRoute( + navigateToNewClass = navigateToNewOrganization, + navigateToProfile = navigateToProfile, + navigateToHome = navigateToHome, + onShowSnackBar = onShowSnackBar + ) + } + + composable(route = TeacherMainRoute.profileRoute) { + TeacherProfileRoute( + navigateToOrganizationList = onBackClick, + navigateToSignIn = navigateToSignIn, + onShowSnackBar = onShowSnackBar, + onBackClick = onBackClick + ) + } + + composable(route = TeacherMainRoute.newOrganizationRoute) { + NewOrganizationRoute( + navigateToOrganizationList = onBackClick, + onBackClick = onBackClick, + onShowSnackBar = onShowSnackBar, + ) + } + +} + +object TeacherMainRoute { + const val defaultRoute = "organization-list" + const val newOrganizationRoute = "new-organization" + const val profileRoute = "profile" +} \ No newline at end of file diff --git a/android/feature/teacher/main/src/main/java/com/sixkids/teacher/main/neworganization/NewOrganizationContract.kt b/android/feature/teacher/main/src/main/java/com/sixkids/teacher/main/neworganization/NewOrganizationContract.kt new file mode 100644 index 00000000..baba7c00 --- /dev/null +++ b/android/feature/teacher/main/src/main/java/com/sixkids/teacher/main/neworganization/NewOrganizationContract.kt @@ -0,0 +1,17 @@ +package com.sixkids.teacher.main.neworganization + +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState + +sealed interface NewOrganizationEffect : SideEffect{ + data object NavigateToOrganizationList : NewOrganizationEffect + data class OnShowSnackBar(val tkn : SnackbarToken) : NewOrganizationEffect +} + +data class NewOrganizationState( + val isLoading: Boolean = false, + val name: String = "", + val grade: String = "", + val classNo: String = "", +) : UiState \ No newline at end of file diff --git a/android/feature/teacher/main/src/main/java/com/sixkids/teacher/main/neworganization/NewOrganizationScreen.kt b/android/feature/teacher/main/src/main/java/com/sixkids/teacher/main/neworganization/NewOrganizationScreen.kt new file mode 100644 index 00000000..4b6edec3 --- /dev/null +++ b/android/feature/teacher/main/src/main/java/com/sixkids/teacher/main/neworganization/NewOrganizationScreen.kt @@ -0,0 +1,141 @@ +package com.sixkids.teacher.main.neworganization + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.sixkids.designsystem.component.button.UlbanFilledButton +import com.sixkids.designsystem.component.screen.UlbanTopSection +import com.sixkids.designsystem.component.textfield.InputTextType +import com.sixkids.designsystem.component.textfield.UlbanUnderLineTextField +import com.sixkids.designsystem.theme.UlbanTheme +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.teacher.main.R +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.extension.collectWithLifecycle + +@Composable +fun NewOrganizationRoute( + viewModel: NewOrganizationViewModel = hiltViewModel(), + navigateToOrganizationList: () -> Unit, + onBackClick: () -> Unit, + onShowSnackBar: (SnackbarToken) -> Unit +) { + val uiState = viewModel.uiState.collectAsStateWithLifecycle().value + + viewModel.sideEffect.collectWithLifecycle { sideEffect -> + when (sideEffect) { + NewOrganizationEffect.NavigateToOrganizationList -> navigateToOrganizationList() + is NewOrganizationEffect.OnShowSnackBar -> onShowSnackBar(sideEffect.tkn) + } + } + + NewOrganizationScreen( + uiState = uiState, + onNewClassClick = viewModel::newClassClick, + onBackClick = onBackClick, + onUpdateName = viewModel::updateSchoolName, + onUpdateGrade = viewModel::updateSchoolGrade, + onUpdateClass = viewModel::updateSchoolClass + ) +} + +@Composable +fun NewOrganizationScreen( + paddingValues: PaddingValues = PaddingValues(20.dp), + uiState: NewOrganizationState = NewOrganizationState(), + onNewClassClick: () -> Unit = {}, + onBackClick: () -> Unit = {}, + onUpdateName: (String) -> Unit = {}, + onUpdateGrade: (String) -> Unit = {}, + onUpdateClass: (String) -> Unit = {}, +) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + Column( + modifier = Modifier + .fillMaxSize(), + horizontalAlignment = Alignment.Start, + ) { + UlbanTopSection(stringResource(id = R.string.new_organization_title), onBackClick) + + Spacer(modifier = Modifier.height(36.dp)) + + Text( + text = stringResource(id = R.string.new_organization_name), + style = UlbanTypography.bodyLarge, + modifier = Modifier.padding(top = 10.dp, bottom = 10.dp) + ) + UlbanUnderLineTextField( + text = uiState.name, + hint = stringResource(id = R.string.new_organization_name_hint), + onTextChange = onUpdateName, + onIconClick = { + onUpdateName("") + } + ) + + Text( + text = stringResource(id = R.string.new_organization_grade), + style = UlbanTypography.bodyLarge, + modifier = Modifier.padding(top = 20.dp, bottom = 10.dp) + ) + UlbanUnderLineTextField( + text = uiState.grade, + hint = stringResource(id = R.string.new_organization_grade_hint), + inputTextType = InputTextType.GRADE, + onTextChange = onUpdateGrade, + onIconClick = { + onUpdateGrade("") + } + ) + + Text( + text = stringResource(id = R.string.new_organization_class), + style = UlbanTypography.bodyLarge, + modifier = Modifier.padding(top = 20.dp, bottom = 10.dp) + ) + UlbanUnderLineTextField( + text = uiState.classNo, + hint = stringResource(id = R.string.new_organization_class_hint), + inputTextType = InputTextType.CLASS, + onTextChange = onUpdateClass, + onIconClick = { + onUpdateClass("") + } + ) + + Spacer(modifier = Modifier.weight(1f)) + + UlbanFilledButton( + text = "완료", + onClick = onNewClassClick, + modifier = Modifier.fillMaxWidth() + ) + } + } +} + +@Composable +@Preview(showBackground = true) +fun NewOrganizationScreenPreview() { + UlbanTheme { + NewOrganizationScreen() + } +} \ No newline at end of file diff --git a/android/feature/teacher/main/src/main/java/com/sixkids/teacher/main/neworganization/NewOrganizationViewModel.kt b/android/feature/teacher/main/src/main/java/com/sixkids/teacher/main/neworganization/NewOrganizationViewModel.kt new file mode 100644 index 00000000..a6eaf94b --- /dev/null +++ b/android/feature/teacher/main/src/main/java/com/sixkids/teacher/main/neworganization/NewOrganizationViewModel.kt @@ -0,0 +1,51 @@ +package com.sixkids.teacher.main.neworganization + +import android.util.Log +import androidx.lifecycle.viewModelScope +import com.sixkids.domain.usecase.organization.NewOrganizationUseCase +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +private const val TAG = "D107" + +@HiltViewModel +class NewOrganizationViewModel @Inject constructor( + private val newOrganizationUseCase: NewOrganizationUseCase +) : BaseViewModel(NewOrganizationState()) { + + fun updateSchoolName(name: String) { + intent { copy(name = name) } + } + + fun updateSchoolGrade(grade: String) { + intent { copy(grade = grade) } + } + + fun updateSchoolClass(classNo: String) { + intent { copy(classNo = classNo) } + } + + fun newClassClick() { + val name = "${uiState.value.name}\n${uiState.value.grade}학년 ${uiState.value.classNo}반" + Log.d(TAG, "newClassClick: $name") + viewModelScope.launch { + newOrganizationUseCase(name) + .onSuccess { + postSideEffect(NewOrganizationEffect.NavigateToOrganizationList) + }.onFailure { + Log.e(TAG, "newClassClick: ", it) + postSideEffect( + NewOrganizationEffect.OnShowSnackBar( + SnackbarToken( + it.message ?: "알 수 없는 오류가 발생했습니다." + ) + ) + ) + } + } + } + +} \ No newline at end of file diff --git a/android/feature/teacher/main/src/main/java/com/sixkids/teacher/main/organization/OrganizationContract.kt b/android/feature/teacher/main/src/main/java/com/sixkids/teacher/main/organization/OrganizationContract.kt new file mode 100644 index 00000000..378fcb37 --- /dev/null +++ b/android/feature/teacher/main/src/main/java/com/sixkids/teacher/main/organization/OrganizationContract.kt @@ -0,0 +1,21 @@ +package com.sixkids.teacher.main.organization + +import com.sixkids.model.Organization +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState + +sealed interface OrganizationListEffect: SideEffect{ + data object NavigateToNewClass : OrganizationListEffect + data object NavigateToProfile : OrganizationListEffect + data object NavigateToHome : OrganizationListEffect + data class OnShowSnackBar(val tkn : SnackbarToken) : OrganizationListEffect +} + +data class OrganizationListState( + val isLoading: Boolean = false, + val name: String = "", + val profilePhoto: String = "", + val organizationList: List = emptyList(), + +) : UiState \ No newline at end of file diff --git a/android/feature/teacher/main/src/main/java/com/sixkids/teacher/main/organization/OrganizationScreen.kt b/android/feature/teacher/main/src/main/java/com/sixkids/teacher/main/organization/OrganizationScreen.kt new file mode 100644 index 00000000..ead64ee4 --- /dev/null +++ b/android/feature/teacher/main/src/main/java/com/sixkids/teacher/main/organization/OrganizationScreen.kt @@ -0,0 +1,294 @@ +package com.sixkids.teacher.main.organization + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +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.PaddingValues +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.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AccountCircle +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil.compose.AsyncImage +import com.google.android.gms.tasks.OnCompleteListener +import com.google.firebase.messaging.FirebaseMessaging +import com.sixkids.designsystem.component.screen.LoadingScreen +import com.sixkids.designsystem.theme.Blue +import com.sixkids.designsystem.theme.BlueDark +import com.sixkids.designsystem.theme.Green +import com.sixkids.designsystem.theme.Orange +import com.sixkids.designsystem.theme.Purple +import com.sixkids.designsystem.theme.Red +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.designsystem.theme.Yellow +import com.sixkids.model.Organization +import com.sixkids.teacher.main.R +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.extension.collectWithLifecycle +import kotlin.math.absoluteValue + +@Composable +fun OrganizationListRoute( + viewModel: OrganizationViewModel = hiltViewModel(), + navigateToNewClass: () -> Unit, + navigateToProfile: () -> Unit, + navigateToHome: () -> Unit, + onShowSnackBar: (SnackbarToken) -> Unit +) { + val uiState = viewModel.uiState.collectAsStateWithLifecycle().value + + viewModel.sideEffect.collectWithLifecycle { sideEffect -> + when (sideEffect) { + OrganizationListEffect.NavigateToNewClass -> navigateToNewClass() + OrganizationListEffect.NavigateToProfile -> navigateToProfile() + OrganizationListEffect.NavigateToHome -> navigateToHome() + is OrganizationListEffect.OnShowSnackBar -> onShowSnackBar(sideEffect.tkn) + } + } + + LaunchedEffect(key1 = Unit) { + viewModel.initData() + } + + OrganizationListScreen( + uiState = uiState, + onNewClassClick = viewModel::newOrganizationClick, + onProfileClick = viewModel::profileClick, + onClassClick = { classId -> + viewModel.organizationClick(classId) + } + ) + + + + FirebaseMessaging.getInstance().token.addOnCompleteListener( + OnCompleteListener { task -> + if (!task.isSuccessful) { + return@OnCompleteListener + } + if (task.result != null) { + viewModel.onTokenRefresh(task.result) + } + }, + ) + +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun OrganizationListScreen( + uiState: OrganizationListState = OrganizationListState(), + onNewClassClick: () -> Unit = {}, + onProfileClick: () -> Unit = {}, + onClassClick: (Int) -> Unit = {} +) { + Box(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + val pagerState = rememberPagerState(pageCount = { uiState.organizationList.size }) + Icon( + imageVector = Icons.Outlined.AccountCircle, + contentDescription = "profile", + modifier = Modifier + .padding(24.dp) + .size(40.dp) + .align(Alignment.End) + .clickable { onProfileClick() }, + ) + + UserInfoSection(name = uiState.name, photo = uiState.profilePhoto) + + OrganizationListSection( + pagerState = pagerState, + organizationList = uiState.organizationList, + onClassClick = onClassClick + ) + + Spacer(modifier = Modifier.weight(1f)) + + NewClassButton( + Modifier + .padding(18.dp, 28.dp) + .align(Alignment.End), + onNewClassClick = onNewClassClick + ) + + } + if (uiState.isLoading) { + LoadingScreen() + } + } + +} + +@Composable +fun UserInfoSection(name: String, photo: String) { + Column { + AsyncImage( + model = photo, + contentDescription = "profile image", + placeholder = painterResource(id = com.sixkids.designsystem.R.drawable.teacher_man), + modifier = Modifier + .padding(20.dp) + .size(200.dp) + .clip(RoundedCornerShape(16.dp)) + .align(Alignment.CenterHorizontally), + contentScale = ContentScale.Crop + ) + + Text( + text = String.format(stringResource(id = R.string.organization_welcome), name), + style = UlbanTypography.titleMedium, + modifier = Modifier + .padding(0.dp, 0.dp, 0.dp, 60.dp) + .align(Alignment.CenterHorizontally) + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun OrganizationListSection( + pagerState: PagerState, + organizationList: List, + onClassClick: (Int) -> Unit +) { + val backgroundColorList = listOf(Red, Blue, Orange, Yellow, Green, Purple) + + val screenWidthDp = with(LocalDensity.current) { + LocalContext.current.resources.displayMetrics.widthPixels.toDp() + } + val cardWidth = 220.dp + val horizontalPadding = (screenWidthDp - cardWidth) / 2 + + if (organizationList.isEmpty()) { + Text( + text = stringResource(id = R.string.organization_no_organization), + style = UlbanTypography.titleMedium, + ) + } else { + + HorizontalPager( + pageSpacing = 10.dp, + state = pagerState, + contentPadding = PaddingValues(horizontal = horizontalPadding), + modifier = Modifier.fillMaxWidth() + ) { + val item = organizationList[it] + val name = item.name.split("\n") + Card( + modifier = Modifier + .padding(10.dp) + .size(cardWidth) + .clickable { onClassClick(item.id) } + .graphicsLayer { + val pageOffset = ( + (pagerState.currentPage - it) + pagerState + .currentPageOffsetFraction + ).absoluteValue + + alpha = lerp( + start = 0.5f, + stop = 1f, + fraction = 1f - pageOffset.coerceIn(0f, 1f) + ) + }, + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = backgroundColorList[it % backgroundColorList.size]), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Column( + modifier = Modifier + .padding(20.dp, 40.dp) + .fillMaxSize(), + verticalArrangement = Arrangement.SpaceBetween, + horizontalAlignment = Alignment.Start + ) { + Text(text = name[0], style = UlbanTypography.titleMedium) + Text( + text = name[1], + style = UlbanTypography.titleMedium.copy(fontWeight = FontWeight.SemiBold) + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Image( + painter = painterResource(id = com.sixkids.designsystem.R.drawable.member), + contentDescription = "member count" + ) + + Text( + text = "${item.memberCount}명", + style = UlbanTypography.bodyLarge.copy(fontWeight = FontWeight.SemiBold) + ) + } + } + } + } + } +} + +@Composable +fun NewClassButton( + modifier: Modifier = Modifier, + onNewClassClick: () -> Unit +) { + Button( + onClick = { onNewClassClick() }, + modifier = modifier, + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.buttonColors(Blue, contentColor = BlueDark), + elevation = ButtonDefaults.buttonElevation(defaultElevation = 4.dp, pressedElevation = 8.dp) + ) { + Row(modifier = Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(imageVector = Icons.Outlined.Add, contentDescription = "new class") + Spacer(modifier = Modifier.width(2.dp)) + Text( + text = stringResource(id = R.string.organization_new_class), + style = UlbanTypography.bodyMedium.copy(fontWeight = FontWeight.SemiBold) + ) + } + } +} + +@Composable +@Preview(showBackground = true) +fun OrganizationListScreenPreview() { + OrganizationListScreen() +} diff --git a/android/feature/teacher/main/src/main/java/com/sixkids/teacher/main/organization/OrganizationViewModel.kt b/android/feature/teacher/main/src/main/java/com/sixkids/teacher/main/organization/OrganizationViewModel.kt new file mode 100644 index 00000000..8066a089 --- /dev/null +++ b/android/feature/teacher/main/src/main/java/com/sixkids/teacher/main/organization/OrganizationViewModel.kt @@ -0,0 +1,76 @@ +package com.sixkids.teacher.main.organization + +import android.util.Log +import androidx.lifecycle.viewModelScope +import com.sixkids.domain.usecase.organization.GetOrganizationListUseCase +import com.sixkids.domain.usecase.organization.SaveSelectedOrganizationIdUseCase +import com.sixkids.domain.usecase.organization.SaveSelectedOrganizationNameUseCase +import com.sixkids.domain.usecase.user.GetUserInfoUseCase +import com.sixkids.domain.usecase.user.UpdateFCMTokenUseCase +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import javax.inject.Inject + +private const val TAG = "D107" +@HiltViewModel +class OrganizationViewModel @Inject constructor( + private val getUserInfoUseCase: GetUserInfoUseCase, + private val getOrganizationListUseCase: GetOrganizationListUseCase, + private val saveSelectedOrganizationIdUseCase: SaveSelectedOrganizationIdUseCase, + private val saveSelectedOrganizationNameUseCase: SaveSelectedOrganizationNameUseCase, + private val updateFCMTokenUseCase: UpdateFCMTokenUseCase +) : BaseViewModel(OrganizationListState()) { + + + fun initData() { + viewModelScope.launch { + intent { copy(isLoading = true) } + + val userInfoJob = async { getUserInfoUseCase() } + val organizationListJob = async { getOrganizationListUseCase() } + + val userInfoResult = userInfoJob.await() + .onSuccess { + intent { copy(name = it.name, profilePhoto = it.photo) } + }.onFailure { + postSideEffect(OrganizationListEffect.OnShowSnackBar(SnackbarToken(message = it.message ?: "알 수 없는 오류가 발생했습니다."))) + } + val organizationListResult = organizationListJob.await() + .onSuccess { + intent { copy(organizationList = it) } + }.onFailure { + postSideEffect(OrganizationListEffect.OnShowSnackBar(SnackbarToken(message = it.message ?: "알 수 없는 오류가 발생했습니다."))) + } + intent { copy(isLoading = false) } + } + } + + fun newOrganizationClick(){ + postSideEffect(OrganizationListEffect.NavigateToNewClass) + } + + fun profileClick(){ + postSideEffect(OrganizationListEffect.NavigateToProfile) + } + + fun organizationClick(id: Int){ + viewModelScope.launch { + saveSelectedOrganizationIdUseCase(id) + currentState.organizationList.find { it.id == id }?.let { + saveSelectedOrganizationNameUseCase(it.name) + } + postSideEffect(OrganizationListEffect.NavigateToHome) + } + } + + fun onTokenRefresh(fcmToken: String) { + viewModelScope.launch { + updateFCMTokenUseCase(fcmToken).onFailure { + Log.d(TAG, "onTokenRefresh: 토큰 갱신 실패 ${it.message}") + } + } + } +} diff --git a/android/feature/teacher/main/src/main/java/com/sixkids/teacher/main/profile/ProfileContract.kt b/android/feature/teacher/main/src/main/java/com/sixkids/teacher/main/profile/ProfileContract.kt new file mode 100644 index 00000000..1c7f1831 --- /dev/null +++ b/android/feature/teacher/main/src/main/java/com/sixkids/teacher/main/profile/ProfileContract.kt @@ -0,0 +1,26 @@ +package com.sixkids.teacher.main.profile + +import android.graphics.Bitmap +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState + +sealed interface ProfileEffect : SideEffect{ + data object NavigateToSignIn : ProfileEffect + data object NavigateToOrganizationList : ProfileEffect + data class OnShowSnackBar(val tkn: SnackbarToken) : ProfileEffect +} + +data class ProfileState( + val isLoading: Boolean = false, + val name: String = "", + val gender: Gender? = null, + val originalProfilePhoto: String? = null, + val changedProfileDefaultPhoto: Int? = null, + val changedProfileUserPhoto: Bitmap? = null +) : UiState + +enum class Gender{ + MAN, + WOMAN +} \ No newline at end of file diff --git a/android/feature/teacher/main/src/main/java/com/sixkids/teacher/main/profile/ProfileScreen.kt b/android/feature/teacher/main/src/main/java/com/sixkids/teacher/main/profile/ProfileScreen.kt new file mode 100644 index 00000000..0cf510af --- /dev/null +++ b/android/feature/teacher/main/src/main/java/com/sixkids/teacher/main/profile/ProfileScreen.kt @@ -0,0 +1,378 @@ +package com.sixkids.teacher.main.profile + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.ImageDecoder +import android.os.Build +import android.provider.MediaStore +import android.util.Log +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +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.aspectRatio +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.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil.compose.AsyncImage +import com.sixkids.designsystem.R as DesignSystemR +import com.sixkids.designsystem.component.button.UlbanFilledButton +import com.sixkids.designsystem.component.screen.LoadingScreen +import com.sixkids.designsystem.component.screen.UlbanTopSection +import com.sixkids.designsystem.theme.Cream +import com.sixkids.designsystem.theme.Red +import com.sixkids.designsystem.theme.RedDark +import com.sixkids.designsystem.theme.UlbanTheme +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.teacher.main.R +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.extension.collectWithLifecycle +import java.io.File +import java.io.FileOutputStream +import java.io.IOException + +private const val TAG = "D107" + +@Composable +fun TeacherProfileRoute( + viewModel: ProfileViewModel = hiltViewModel(), + navigateToOrganizationList: () -> Unit, + navigateToSignIn: () -> Unit, + onShowSnackBar: (SnackbarToken) -> Unit, + onBackClick: () -> Unit +) { + val context = LocalContext.current + val uiState = viewModel.uiState.collectAsStateWithLifecycle().value + + LaunchedEffect(key1 = Unit) { + viewModel.initData() + } + + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickVisualMedia() + ) { uri -> + uri?.let { + try { + val bitmap = if (Build.VERSION.SDK_INT < 28) { + MediaStore.Images.Media.getBitmap(context.contentResolver, it) + } else { + ImageDecoder.decodeBitmap( + ImageDecoder.createSource( + context.contentResolver, + it + ) + ) + } + viewModel.onProfilePhotoSelected(bitmap) + } catch (e: IOException) { + Log.e(TAG, "Error decoding bitmap", e) + } + } + } + + viewModel.sideEffect.collectWithLifecycle { + when (it) { + ProfileEffect.NavigateToOrganizationList -> navigateToOrganizationList() + is ProfileEffect.OnShowSnackBar -> onShowSnackBar(it.tkn) + ProfileEffect.NavigateToSignIn -> navigateToSignIn() + } + } + + TeacherProfileScreen( + uiState = uiState, + onClickPhoto = { resId -> + when (resId) { + DesignSystemR.drawable.camera -> + launcher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) + + DesignSystemR.drawable.teacher_man -> + viewModel.onProfileDefaultPhotoSelected(resId, Gender.MAN) + + DesignSystemR.drawable.teacher_woman -> + viewModel.onProfileDefaultPhotoSelected(resId, Gender.WOMAN) + } + }, + onDoneClick = { + viewModel.onChangeDoneClick( + saveBitmapToFile(context, uiState.changedProfileUserPhoto, "profile.jpg") + ) + }, + onSignOutClick = viewModel::onSignOutClick, + onBackClick = onBackClick + ) + +} + +@Composable +fun TeacherProfileScreen( + uiState: ProfileState = ProfileState(), + onClickPhoto: (Int) -> Unit = {}, + onDoneClick: () -> Unit = {}, + onSignOutClick: () -> Unit = {}, + onBackClick: () -> Unit = {} +) { + val imageMan = DesignSystemR.drawable.teacher_man + val imageWoman = DesignSystemR.drawable.teacher_woman + + Box(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(21.dp) + ) { + UlbanTopSection( + stringResource(id = R.string.profile_welcome, uiState.name), + onBackClick + ) + + Spacer(modifier = Modifier.height(60.dp)) + + SelectedPhotoCard( + uiState.changedProfileDefaultPhoto, + uiState.originalProfilePhoto, + uiState.changedProfileUserPhoto, + modifier = Modifier + .padding(10.dp) + .size(180.dp) + .align(Alignment.CenterHorizontally), + ) + + Spacer(modifier = Modifier.height(60.dp)) + + Row { + PhotoCard( + modifier = Modifier + .padding(10.dp) + .weight(1f) + .aspectRatio(1f), + img = imageMan, + onClickPhoto = onClickPhoto + ) + + PhotoCard( + modifier = Modifier + .padding(10.dp) + .weight(1f) + .aspectRatio(1f), + img = imageWoman, + onClickPhoto = onClickPhoto + ) + + PhotoCard( + modifier = Modifier + .padding(10.dp) + .weight(1f) + .aspectRatio(1f), + img = DesignSystemR.drawable.camera, + onClickPhoto = onClickPhoto + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + BottomSection(onDoneClick = onDoneClick, onSignOutClick = onSignOutClick) + + } + if (uiState.isLoading) { + LoadingScreen() + } + } +} + +@Composable +fun SignUpPhotoTopSection(name: String, onDoneClick: () -> Unit) { + Column( + horizontalAlignment = Alignment.Start, + ) { + Image( + painter = painterResource(id = DesignSystemR.drawable.ic_arrow_back), + contentDescription = "back button", + modifier = Modifier.clickable { onDoneClick() } + ) + + Text( + text = String.format( + stringResource(id = com.sixkids.teacher.main.R.string.profile_welcome), + name + ), + style = UlbanTypography.titleMedium, + modifier = Modifier.padding(top = 20.dp) + ) + } +} + +@Composable +fun BottomSection( + onDoneClick: () -> Unit, + onSignOutClick: () -> Unit, + onExitClick: () -> Unit = { } +) { + Column { + UlbanFilledButton( + text = stringResource(id = com.sixkids.teacher.main.R.string.profile_done), + onClick = onDoneClick, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(4.dp)) + + UlbanFilledButton( + text = stringResource(id = com.sixkids.teacher.main.R.string.profile_sign_out), + onClick = onSignOutClick, + modifier = Modifier.fillMaxWidth(), + color = Red, + textColor = RedDark + ) + + Text( + text = stringResource(id = com.sixkids.teacher.main.R.string.profile_exit), + style = UlbanTypography.titleSmall.copy(textDecoration = TextDecoration.Underline), + modifier = Modifier + .padding(10.dp) + .align(Alignment.CenterHorizontally) + .clickable { onExitClick() } + ) + } +} + +@Composable +fun SelectedPhotoCard( + defaultImage: Int?, + originalImage: String?, + bitmap: Bitmap?, + modifier: Modifier = Modifier +) { + Card( + elevation = CardDefaults.cardElevation( + defaultElevation = 4.dp, + pressedElevation = 8.dp + ), + colors = CardDefaults.cardColors( + containerColor = Cream + ), + modifier = modifier, + ) { + Column( + modifier = Modifier.fillMaxSize(), + ) { + if (originalImage == null && bitmap == null && defaultImage == null) { + Image( + painter = painterResource( + id = DesignSystemR.drawable.teacher_man + ), + contentDescription = "selected photo", + modifier = Modifier.fillMaxSize(), + ) + + } else if (originalImage != null) { + if (bitmap == null && defaultImage == null) { + AsyncImage( + model = originalImage, + contentDescription = "original image", + modifier = Modifier.fillMaxWidth(), + contentScale = ContentScale.Crop + ) + } else { + if (bitmap != null) { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = "selected photo", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } else { + Image( + painter = painterResource( + id = defaultImage!! + ), + contentDescription = "selected photo", + modifier = Modifier.fillMaxSize(), + ) + } + } + } + } + } +} + +@Composable +fun PhotoCard(modifier: Modifier = Modifier, img: Int, onClickPhoto: (Int) -> Unit) { + Card( + elevation = CardDefaults.cardElevation( + defaultElevation = 4.dp, + pressedElevation = 8.dp + ), + colors = CardDefaults.cardColors( + containerColor = Cream + ), + modifier = modifier.clickable { + onClickPhoto(img) + }, + ) { + Column( + modifier = Modifier.fillMaxSize(), + ) { + Image( + painter = painterResource(id = img), + contentDescription = "profile", + modifier = Modifier, + ) + } + } +} + +fun saveBitmapToFile(context: Context, bitmap: Bitmap?, fileName: String): File? { + val directory = context.getExternalFilesDir(null) ?: return null + if (bitmap == null) return null + val file = File(directory, fileName) + var fileOutputStream: FileOutputStream? = null + + try { + fileOutputStream = FileOutputStream(file) + bitmap.compress(Bitmap.CompressFormat.JPEG, 100, fileOutputStream) + fileOutputStream.flush() + } catch (e: Exception) { + e.printStackTrace() + return null + } finally { + try { + fileOutputStream?.close() + } catch (e: Exception) { + e.printStackTrace() + } + } + + return file +} + +@Composable +@Preview(showBackground = true) +fun TeacherProfileScreenPreview() { + UlbanTheme { + TeacherProfileScreen() + } +} \ No newline at end of file diff --git a/android/feature/teacher/main/src/main/java/com/sixkids/teacher/main/profile/ProfileViewModel.kt b/android/feature/teacher/main/src/main/java/com/sixkids/teacher/main/profile/ProfileViewModel.kt new file mode 100644 index 00000000..09021f94 --- /dev/null +++ b/android/feature/teacher/main/src/main/java/com/sixkids/teacher/main/profile/ProfileViewModel.kt @@ -0,0 +1,116 @@ +package com.sixkids.teacher.main.profile + +import android.graphics.Bitmap +import android.util.Log +import androidx.annotation.DrawableRes +import androidx.lifecycle.viewModelScope +import com.sixkids.domain.usecase.user.GetUserInfoUseCase +import com.sixkids.domain.usecase.user.SignOutUseCase +import com.sixkids.domain.usecase.user.UpdateUserProfilePhotoUseCase +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import java.io.File +import javax.inject.Inject + +private const val TAG = "D107" +@HiltViewModel +class ProfileViewModel @Inject constructor( + private val getUserInfoUseCase: GetUserInfoUseCase, + private val updateUserProfilePhotoUseCase: UpdateUserProfilePhotoUseCase, + private val signOutUseCase: SignOutUseCase +) : BaseViewModel(ProfileState()) { + + fun initData() { + viewModelScope.launch { + getUserInfoUseCase() + .onSuccess { + intent { + copy( + name = it.name, + originalProfilePhoto = it.photo + ) + } + }.onFailure { + postSideEffect( + ProfileEffect.OnShowSnackBar( + SnackbarToken( + it.message ?: "알 수 없는 오류가 발생했습니다." + ) + ) + ) + } + + } + } + + fun onProfilePhotoSelected(bitmap: Bitmap) { + intent { + copy( + changedProfileUserPhoto = bitmap, + changedProfileDefaultPhoto = null, + gender = null + ) + } + } + + fun onProfileDefaultPhotoSelected(@DrawableRes photo: Int, gender: Gender) { + intent { + copy( + changedProfileDefaultPhoto = photo, + changedProfileUserPhoto = null, + gender = gender + ) + } + } + + fun onChangeDoneClick(newProfilePhoto: File?) { + viewModelScope.launch { + intent { copy(isLoading = true) } + var defaultImage = 0 + if (newProfilePhoto == null && uiState.value.changedProfileDefaultPhoto == null) { + // 변경사항 없음 뒤로가기 + postSideEffect(ProfileEffect.NavigateToOrganizationList) + } else { + defaultImage = when (newProfilePhoto) { + null -> { + when (uiState.value.gender) { + null -> 0 + Gender.MAN -> 1 + Gender.WOMAN -> 2 + } + } + else -> 0 + } + } + + updateUserProfilePhotoUseCase(newProfilePhoto, defaultImage) + .onSuccess { + postSideEffect(ProfileEffect.NavigateToOrganizationList) + }.onFailure { + Log.d(TAG, "onChangeDoneClick: ${it.message}") + postSideEffect( + ProfileEffect.OnShowSnackBar( + SnackbarToken( + it.message ?: "알 수 없는 오류가 발생했습니다." + ) + ) + ) + } + intent { copy(isLoading = false) } + } + } + + fun onSignOutClick() { + viewModelScope.launch { + if(signOutUseCase()){ + postSideEffect(ProfileEffect.NavigateToSignIn) + }else{ + ProfileEffect.OnShowSnackBar( + SnackbarToken("로그아웃에 실패했습니다. 다시 시도해주세요.") + ) + } + } + } +} \ No newline at end of file diff --git a/android/feature/teacher/main/src/main/res/values/strings.xml b/android/feature/teacher/main/src/main/res/values/strings.xml new file mode 100644 index 00000000..e6f21639 --- /dev/null +++ b/android/feature/teacher/main/src/main/res/values/strings.xml @@ -0,0 +1,22 @@ + + + + %s 선생님 환영합니다 + 학급이 없습니다 + 학급 추가 + + + 안녕하세요 %s 선생님! + 완료 + 로그아웃 + 회원 탈퇴 + + + 새로운 학급을 만듭니다 + 학교 이름 + 학년 + + 학교명을 입력해주세요 + 학년을 입력해주세요 + 반을 입력해주세요 + \ No newline at end of file diff --git a/android/feature/teacher/main/src/test/java/com/sixkids/teacher/main/ExampleUnitTest.kt b/android/feature/teacher/main/src/test/java/com/sixkids/teacher/main/ExampleUnitTest.kt new file mode 100644 index 00000000..10a6a11b --- /dev/null +++ b/android/feature/teacher/main/src/test/java/com/sixkids/teacher/main/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.sixkids.teacher.main + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/android/feature/teacher/manageclass/build.gradle.kts b/android/feature/teacher/manageclass/build.gradle.kts index 7e8f96dd..a50da4db 100644 --- a/android/feature/teacher/manageclass/build.gradle.kts +++ b/android/feature/teacher/manageclass/build.gradle.kts @@ -7,4 +7,7 @@ android { } dependencies { + implementation(projects.core.designsystem) + + implementation(libs.bundles.paging) } diff --git a/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/chattingfilter/ChattingFilterContract.kt b/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/chattingfilter/ChattingFilterContract.kt new file mode 100644 index 00000000..7083017c --- /dev/null +++ b/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/chattingfilter/ChattingFilterContract.kt @@ -0,0 +1,16 @@ +package com.sixkids.teacher.manageclass.chattingfilter + +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState + +sealed interface ChattingFilterEffect : SideEffect { + data object refreshChattingFilterWords : ChattingFilterEffect + data class onShowSnackBar(val message: String) : ChattingFilterEffect +} + +data class ChattingFilterState( + val isLoading: Boolean = false, + val isShowDialog: Boolean = false, + val classString: String = "", + val dialogText: String = "", +) : UiState \ No newline at end of file diff --git a/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/chattingfilter/ChattingFilterScreen.kt b/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/chattingfilter/ChattingFilterScreen.kt new file mode 100644 index 00000000..73590528 --- /dev/null +++ b/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/chattingfilter/ChattingFilterScreen.kt @@ -0,0 +1,151 @@ +package com.sixkids.teacher.manageclass.chattingfilter + +import android.util.Log +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import com.sixkids.designsystem.component.appbar.UlbanDetailAppBar +import com.sixkids.designsystem.component.button.EditFAB +import com.sixkids.designsystem.component.screen.LoadingScreen +import com.sixkids.designsystem.theme.Blue +import com.sixkids.designsystem.theme.BlueDark +import com.sixkids.designsystem.theme.Yellow +import com.sixkids.designsystem.theme.YellowDark +import com.sixkids.model.ChatFilterWord +import com.sixkids.teacher.manageclass.R +import com.sixkids.teacher.manageclass.chattingfilter.component.ChatFilterWordDialog +import com.sixkids.teacher.manageclass.chattingfilter.component.ChattingFilterItem +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.extension.collectWithLifecycle +import com.sixkids.designsystem.R as UlbanRes + +@Composable +fun ChattingFilterRoute( + viewModel: ChattingFilterViewModel = hiltViewModel(), + onShowSnackBar: (SnackbarToken) -> Unit, +) { + + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.getChatFilterWords() + } + + viewModel.sideEffect.collectWithLifecycle { + when (it) { + ChattingFilterEffect.refreshChattingFilterWords -> viewModel.getChatFilterWords() + is ChattingFilterEffect.onShowSnackBar -> onShowSnackBar(SnackbarToken(message = it.message)) + } + } + + ChattingFilterScreen( + chattingFilterState = uiState, + chattingFilterWordList = viewModel.chattingFilterWords?.collectAsLazyPagingItems(), + chattingFilterItemDeleteOnClick = { viewModel.deleteChattingFilterWord(it) }, + fabClick = viewModel::showDialog, + dialogCancelOnClick = viewModel::hideDialog, + dialogConfirmOnClick = viewModel::newChattingFilter + ) + +} + +@Composable +fun ChattingFilterScreen( + modifier: Modifier = Modifier, + chattingFilterState: ChattingFilterState = ChattingFilterState(), + chattingFilterWordList: LazyPagingItems? = null, + chattingFilterItemDeleteOnClick: (ChatFilterWord) -> Unit = {}, + dialogCancelOnClick: () -> Unit = {}, + dialogConfirmOnClick: (String) -> Unit = {}, + fabClick: () -> Unit = {} +) { + val listState = rememberLazyListState() + val backPressState by rememberUpdatedState(newValue = chattingFilterState.isShowDialog) + + BackHandler(enabled = backPressState){ + Log.d("TAG", "ChattingFilterScreen: ") + dialogCancelOnClick() + } + + Box( + modifier = modifier.fillMaxSize() + ) { + Column { + UlbanDetailAppBar( + leftIcon = UlbanRes.drawable.chat_filter, + title = stringResource(id = R.string.manage_class_filter_chat), + content = stringResource(id = R.string.manage_class_filter_chat), + topDescription = "", + bottomDescription = "", + color = Yellow + ) + if (chattingFilterWordList != null) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + state = listState, + verticalArrangement = Arrangement.SpaceBetween + ) { + items(chattingFilterWordList.itemCount) { index -> + chattingFilterWordList[index]?.let {word -> + ChattingFilterItem( + text = word.badWord, + deleteOnclick = { chattingFilterItemDeleteOnClick(word) } + ) + Spacer(modifier = Modifier.height(4.dp)) + } + } + } + } + + } + //FAB + EditFAB( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(16.dp), + buttonColor = Yellow, + iconColor = YellowDark, + onClick = fabClick + ) + + if (chattingFilterState.isLoading){ + LoadingScreen() + } + + if (chattingFilterState.isShowDialog){ + ChatFilterWordDialog( + cancelButtonOnClick = dialogCancelOnClick, + confirmButtonOnClick = dialogConfirmOnClick + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun ChattingFilterScreenPreview() { + ChattingFilterScreen() +} \ No newline at end of file diff --git a/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/chattingfilter/ChattingFilterViewModel.kt b/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/chattingfilter/ChattingFilterViewModel.kt new file mode 100644 index 00000000..2c2c6771 --- /dev/null +++ b/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/chattingfilter/ChattingFilterViewModel.kt @@ -0,0 +1,92 @@ +package com.sixkids.teacher.manageclass.chattingfilter + +import android.util.Log +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn +import com.sixkids.domain.usecase.chatting.CreateNewChattingFilterWordUseCase +import com.sixkids.domain.usecase.chatting.DeleteChattingFilterWordUseCase +import com.sixkids.domain.usecase.chatting.GetChattingFilterWordsUseCase +import com.sixkids.domain.usecase.organization.GetSelectedOrganizationIdUseCase +import com.sixkids.model.ChatFilterWord +import com.sixkids.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ChattingFilterViewModel @Inject constructor( + private val getSelectedOrganizationIdUseCase: GetSelectedOrganizationIdUseCase, + private val getChattingFilterWordsUseCase: GetChattingFilterWordsUseCase, + private val deleteChattingFilterWordUseCase: DeleteChattingFilterWordUseCase, + private val createNewChattingFilterWordUseCase: CreateNewChattingFilterWordUseCase +): BaseViewModel( + ChattingFilterState() +){ + private var organizationId: Int? = null + + var chattingFilterWords: Flow>? = null + + fun getChatFilterWords(){ + viewModelScope.launch { + intent { copy(isLoading = true) } + + if (organizationId == null){ + organizationId = getSelectedOrganizationIdUseCase().getOrNull() + } + + if (organizationId != null){ + chattingFilterWords = + getChattingFilterWordsUseCase( + organizationId!! + ).cachedIn(viewModelScope) + } else { + postSideEffect(ChattingFilterEffect.onShowSnackBar("학급 정보를 불러오지 못했어요 ;(")) + } + + intent { copy(isLoading = false) } + } + } + + fun deleteChattingFilterWord(chatFilterWord: ChatFilterWord){ + viewModelScope.launch { + intent { copy(isLoading = true) } + + deleteChattingFilterWordUseCase(chatFilterWord.id) + + postSideEffect(ChattingFilterEffect.refreshChattingFilterWords) + + intent { copy(isLoading = false) } + } + } + + fun newChattingFilter(word: String){ + viewModelScope.launch { + if (organizationId == null){ + organizationId = getSelectedOrganizationIdUseCase().getOrNull() + } + + if (organizationId != null){ + createNewChattingFilterWordUseCase( + organizationId!!.toLong(), + word + ).onSuccess { + postSideEffect(ChattingFilterEffect.refreshChattingFilterWords) + }.onFailure { + postSideEffect(ChattingFilterEffect.onShowSnackBar("필터링 단어 등록에 실패했어요")) + } + hideDialog() + postSideEffect(ChattingFilterEffect.refreshChattingFilterWords) + } else { + postSideEffect(ChattingFilterEffect.onShowSnackBar("학급 정보를 불러오지 못했어요 ;(")) + } + } + } + + + fun hideDialog(){ intent { copy(isShowDialog = false) } + Log.d("TAG", "hideDialog: ")} + fun showDialog(){ intent { copy(isShowDialog = true) } } + +} \ No newline at end of file diff --git a/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/chattingfilter/component/ChatFilterWordDialog.kt b/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/chattingfilter/component/ChatFilterWordDialog.kt new file mode 100644 index 00000000..21c5dc59 --- /dev/null +++ b/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/chattingfilter/component/ChatFilterWordDialog.kt @@ -0,0 +1,88 @@ +package com.sixkids.teacher.manageclass.chattingfilter.component + +import android.service.autofill.OnClickAction +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.sixkids.designsystem.component.button.UlbanFilledButton +import com.sixkids.designsystem.component.dialog.UlbanBasicDialog +import com.sixkids.designsystem.component.textfield.UlbanBasicTextField +import com.sixkids.designsystem.theme.Red +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.teacher.manageclass.R + +@Composable +fun ChatFilterWordDialog( + cancelButtonOnClick: () -> Unit = {}, + confirmButtonOnClick: (String) -> Unit = {} +) { + var text by remember { mutableStateOf("") } + + UlbanBasicDialog { + Column( + modifier = Modifier.width(240.dp) + ) { + Text( + text = stringResource(id = R.string.chatting_filter_word), + style = UlbanTypography.bodyLarge, + ) + Spacer(modifier = Modifier.height(8.dp)) + Card( + modifier = Modifier.border(1.dp, Color.Gray, RoundedCornerShape(6.dp)), + colors = CardDefaults.cardColors( + containerColor = Color.Transparent + ) + ) { + UlbanBasicTextField( + modifier = Modifier.fillMaxWidth(), + text = text, + onTextChange = { text = it }, + ) + } + Spacer(modifier = Modifier.height(20.dp)) + Row { + // 취소 버튼 + UlbanFilledButton( + modifier = Modifier.weight(1f), + text = stringResource(id = R.string.cancel), + color = Red, + onClick = cancelButtonOnClick + ) + Spacer(modifier = Modifier.width(4.dp)) + UlbanFilledButton( + modifier = Modifier.weight(1f), + text = stringResource(id = R.string.ok), + onClick = {confirmButtonOnClick(text)} + ) + } + + } + } + +} + +@Preview(showBackground = true) +@Composable +fun ChatFilterWordDialogPreview() { + UlbanBasicDialog { + ChatFilterWordDialog() + } +} \ No newline at end of file diff --git a/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/chattingfilter/component/ChattingFilterItem.kt b/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/chattingfilter/component/ChattingFilterItem.kt new file mode 100644 index 00000000..b2c96a05 --- /dev/null +++ b/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/chattingfilter/component/ChattingFilterItem.kt @@ -0,0 +1,61 @@ +package com.sixkids.teacher.manageclass.chattingfilter.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.sixkids.designsystem.theme.Cream +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.designsystem.R as UlbanRes + + +@Composable +fun ChattingFilterItem( + modifier: Modifier = Modifier, + text: String = "", + deleteOnclick: () -> Unit = {} +) { + Card( + modifier = modifier + .fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = Cream + ) + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = text, + style = UlbanTypography.bodyLarge + ) + Spacer(modifier = Modifier.weight(1f)) + Icon( + modifier = Modifier.clickable { deleteOnclick() }, + imageVector = ImageVector.vectorResource(id = UlbanRes.drawable.ic_delete), + contentDescription = null + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun ChattingFilterItemPreview() { + ChattingFilterItem( + text = "필터 단어" + ) +} \ No newline at end of file diff --git a/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/invite/ClassInviteContract.kt b/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/invite/ClassInviteContract.kt new file mode 100644 index 00000000..45332534 --- /dev/null +++ b/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/invite/ClassInviteContract.kt @@ -0,0 +1,15 @@ +package com.sixkids.teacher.manageclass.invite + +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState + +sealed interface ClassInviteEffect: SideEffect { + data class onShowSnackBar(val message: String): ClassInviteEffect +} + +data class ClassInviteState( + val isLoading: Boolean = false, + val classString: String = "", + val classInviteCode: String? = null, + val classId: Int? = null +): UiState \ No newline at end of file diff --git a/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/invite/ClassInviteScreen.kt b/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/invite/ClassInviteScreen.kt new file mode 100644 index 00000000..6871438c --- /dev/null +++ b/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/invite/ClassInviteScreen.kt @@ -0,0 +1,159 @@ +package com.sixkids.teacher.manageclass.invite + +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.PaddingValues +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.size +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.sixkids.designsystem.component.appbar.UlbanDetailAppBar +import com.sixkids.designsystem.component.button.UlbanFilledButton +import com.sixkids.designsystem.theme.Green +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.teacher.manageclass.R +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.extension.collectWithLifecycle +import com.sixkids.designsystem.R as UlbanRes + +@Composable +fun ClassInviteRoute( + viewModel: ClassInviteViewModel = hiltViewModel(), + padding: PaddingValues, + onShowSnackBar: (SnackbarToken) -> Unit +) { + var copyTrigger by remember { mutableStateOf(false) } + + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + viewModel.sideEffect.collectWithLifecycle { + when (it) { + is ClassInviteEffect.onShowSnackBar -> onShowSnackBar(SnackbarToken(it.message)) + } + } + + if (copyTrigger){ + if (uiState.classId == null || uiState.classInviteCode == null){ + onShowSnackBar(SnackbarToken("초대 코드를 복사 할 수 없습니다.")) + } else { + copyToClipboardClassInviteCode(uiState.classId!!, uiState.classInviteCode!!) + } + copyTrigger = false + } + + Box(modifier = Modifier.padding(padding)) { + ClassInviteScreen( + classInviteState = uiState, + createInviteCodeButtonOnClick = { viewModel.getInviteCode() }, + copyInviteCodeOnClick = { copyTrigger = true } + ) + } +} + +@Composable +fun ClassInviteScreen( + modifier: Modifier = Modifier, + classInviteState: ClassInviteState = ClassInviteState(), + createInviteCodeButtonOnClick: () -> Unit = {}, + copyInviteCodeOnClick: () -> Unit = {} +) { + Column( + modifier = modifier.fillMaxSize() + ) { + UlbanDetailAppBar( + leftIcon = UlbanRes.drawable.invite, + title = stringResource(id = R.string.manage_class_invite), + content = stringResource(id = R.string.manage_class_invite), + topDescription = "", + bottomDescription = classInviteState.classString, + color = Green + ) + Column( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (classInviteState.classInviteCode == null) { + UlbanFilledButton( + text = stringResource(id = R.string.invite_create_new), + color = Green, + onClick = createInviteCodeButtonOnClick + ) + } else { + Text( + text = stringResource(id = R.string.invite_code_created), + style = UlbanTypography.bodyLarge + ) + Spacer(modifier = Modifier.height(20.dp)) + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = classInviteState.classInviteCode, + style = UlbanTypography.titleLarge.copy( + fontSize = 34.sp + ) + ) + Icon( + modifier = Modifier + .size(30.dp) + .clickable { copyInviteCodeOnClick() }, + imageVector = ImageVector.vectorResource(id = UlbanRes.drawable.ic_copy), + contentDescription = null + ) + } + Spacer(modifier = Modifier.height(20.dp)) + Text( + text = stringResource(id = R.string.invite_code_end), + style = UlbanTypography.bodyLarge + ) + } + } + } +} + +@Composable +private fun copyToClipboardClassInviteCode(classId: Int, classInviteCode: String) { + LocalClipboardManager.current.let { manager -> + manager.setText(AnnotatedString( + "학급 아이디 : ${classId}\n초대 코드 : ${classInviteCode}" + )) + } +} + +@Preview(showBackground = true) +@Composable +fun ClassInviteScreenPreview() { + ClassInviteScreen( + classInviteState = ClassInviteState( + classString = "구미초등학교 1학년 1반", + classInviteCode = "123456" + ) + ) +} \ No newline at end of file diff --git a/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/invite/ClassInviteViewModel.kt b/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/invite/ClassInviteViewModel.kt new file mode 100644 index 00000000..4e0f5cb7 --- /dev/null +++ b/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/invite/ClassInviteViewModel.kt @@ -0,0 +1,45 @@ +package com.sixkids.teacher.manageclass.invite + +import androidx.lifecycle.viewModelScope +import com.sixkids.domain.usecase.organization.GetOrganizaionInviteCodeUseCase +import com.sixkids.domain.usecase.organization.GetSelectedOrganizationIdUseCase +import com.sixkids.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ClassInviteViewModel @Inject constructor( + private val getSelectedOrganizationIdUseCase: GetSelectedOrganizationIdUseCase, + private val getOrganizaionInviteCodeUseCase: GetOrganizaionInviteCodeUseCase +) : BaseViewModel(ClassInviteState()) { + private var organizationId: Int? = null + + private suspend fun getOrganizationId(): Int? { + if (organizationId == null) { + organizationId = getSelectedOrganizationIdUseCase().getOrNull() + } + return organizationId + } + + fun getInviteCode() { + viewModelScope.launch { + intent { copy(isLoading = true) } + + val orgId = getOrganizationId() + if (orgId != null) { + getOrganizaionInviteCodeUseCase(orgId) + .onSuccess { + intent { copy(classInviteCode = it)} + } + .onFailure { + postSideEffect(ClassInviteEffect.onShowSnackBar(it.message ?: "초대코드 생성에 실패했어요 ;(")) + } + } else { + postSideEffect(ClassInviteEffect.onShowSnackBar("학급 정보를 불러오지 못했어요 ;(")) + } + + intent { copy(isLoading = false) } + } + } +} \ No newline at end of file diff --git a/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/main/ManageClassMainContract.kt b/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/main/ManageClassMainContract.kt new file mode 100644 index 00000000..8aed4d6e --- /dev/null +++ b/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/main/ManageClassMainContract.kt @@ -0,0 +1,13 @@ +package com.sixkids.teacher.manageclass.main + +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState + +data class ManageClassMainState( + val isLoading: Boolean = false, + val classString: String = "", +): UiState + +sealed interface ManageClassMainEffect: SideEffect{ + +} \ No newline at end of file diff --git a/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/main/ManageClassMainScreen.kt b/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/main/ManageClassMainScreen.kt new file mode 100644 index 00000000..29839f21 --- /dev/null +++ b/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/main/ManageClassMainScreen.kt @@ -0,0 +1,146 @@ +package com.sixkids.teacher.manageclass.main + +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.ScrollableState +import androidx.compose.foundation.gestures.scrollable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.wrapContentHeight +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.sixkids.designsystem.theme.Blue +import com.sixkids.designsystem.theme.Green +import com.sixkids.designsystem.theme.Orange +import com.sixkids.designsystem.theme.Red +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.designsystem.theme.Yellow +import com.sixkids.designsystem.theme.component.card.ContentAligment +import com.sixkids.designsystem.theme.component.card.ContentCard +import com.sixkids.teacher.manageclass.R +import com.sixkids.designsystem.R as UlbanRes + +@Composable +fun ManageClassMainRoute( + padding: PaddingValues, + viewModel: ManageClassMainViewModel = hiltViewModel(), + navigateToClassSummary: () -> Unit, + navigateToClassSetting: () -> Unit, + navigateToChattingFilter: () -> Unit, + navigateToInvite: () -> Unit, +) { + + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.initData() + } + Box( + modifier = Modifier.padding(padding) + ) { + ManageClassMainScreen( + manageClassMainState = uiState, + summaryCardOnClick = navigateToClassSummary, + settingCardOnClick = navigateToClassSetting, + chattingFilterCardOnClick = navigateToChattingFilter, + inviteCardOnClick = navigateToInvite + ) + } +} + +@Composable +fun ManageClassMainScreen( + modifier: Modifier = Modifier, + manageClassMainState: ManageClassMainState = ManageClassMainState(), + summaryCardOnClick: () -> Unit = {}, + settingCardOnClick: () -> Unit = {}, + chattingFilterCardOnClick: () -> Unit = {}, + inviteCardOnClick: () -> Unit = {}, +) { + Column( + modifier = modifier + .wrapContentHeight() + .fillMaxSize() + .padding(start = 20.dp, end = 20.dp, top = 20.dp) + ) { + Spacer(modifier = Modifier.height(20.dp)) + Text( + text = stringResource(id = R.string.manage_class_title), + style = UlbanTypography.titleLarge, + modifier = Modifier.padding(bottom = 10.dp) + ) + Text( + text = manageClassMainState.classString.replace("\n", " "), + style = UlbanTypography.titleSmall + ) + Spacer(modifier = Modifier.height(20.dp)) + ContentCard( + cardHeight = 130.dp, + modifier = Modifier.padding(start = 40.dp), + imageModifier = Modifier.rotate(60f).padding(10.dp), + contentName = stringResource(id = R.string.manage_class_statistics), + contentImageId = UlbanRes.drawable.statistics, + cardColor = Blue, + contentAligment = ContentAligment.ImageStart_TextEnd, + onclick = summaryCardOnClick + ) + Spacer(modifier = Modifier.height(20.dp)) + ContentCard( + cardHeight = 130.dp, + modifier = Modifier.padding(end = 40.dp), + imageModifier = Modifier.padding(15.dp), + contentName = stringResource(id = R.string.manage_class_setting), + contentImageId = UlbanRes.drawable.setting, + cardColor = Red, + contentAligment = ContentAligment.ImageEnd_TextStart, + onclick = settingCardOnClick + ) + Spacer(modifier = Modifier.height(20.dp)) + ContentCard( + cardHeight = 130.dp, + modifier = Modifier.padding(start = 40.dp), + imageModifier = Modifier.padding(15.dp), + contentName = stringResource(id = R.string.manage_class_filter_chat), + contentImageId = UlbanRes.drawable.chat_filter, + cardColor = Yellow, + contentAligment = ContentAligment.ImageStart_TextEnd, + onclick = chattingFilterCardOnClick + ) + Spacer(modifier = Modifier.height(20.dp)) + ContentCard( + cardHeight = 130.dp, + modifier = Modifier.padding(end = 40.dp), + imageModifier = Modifier.padding(15.dp), + contentName = stringResource(id = R.string.manage_class_invite), + contentImageId = UlbanRes.drawable.invite, + cardColor = Green , + contentAligment = ContentAligment.ImageEnd_TextStart, + onclick = inviteCardOnClick + ) + Spacer(modifier = Modifier.height(20.dp)) + } +} + +@Preview(showBackground = true) +@Composable +fun ManageClassMainScreenPreview() { + ManageClassMainScreen( + manageClassMainState = ManageClassMainState(classString = "인동초등학교 1학년 1반") + ) +} diff --git a/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/main/ManageClassMainViewModel.kt b/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/main/ManageClassMainViewModel.kt new file mode 100644 index 00000000..e5ab9e3f --- /dev/null +++ b/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/main/ManageClassMainViewModel.kt @@ -0,0 +1,24 @@ +package com.sixkids.teacher.manageclass.main + +import androidx.lifecycle.viewModelScope +import com.sixkids.domain.usecase.organization.LoadSelectedOrganizationNameUseCase +import com.sixkids.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ManageClassMainViewModel @Inject constructor( + private val loadSelectedOrganizationNameUseCase: LoadSelectedOrganizationNameUseCase +): BaseViewModel(ManageClassMainState()){ + + fun initData(){ + viewModelScope.launch { + loadSelectedOrganizationNameUseCase().onSuccess { + intent { copy(classString = it) } + }.onFailure { + intent { copy(classString = "")} + } + } + } +} \ No newline at end of file diff --git a/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/navigation/ManageClassNavigation.kt b/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/navigation/ManageClassNavigation.kt new file mode 100644 index 00000000..62ca1f75 --- /dev/null +++ b/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/navigation/ManageClassNavigation.kt @@ -0,0 +1,86 @@ +package com.sixkids.teacher.manageclass.navigation + +import androidx.compose.foundation.layout.PaddingValues +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable +import com.sixkids.teacher.manageclass.chattingfilter.ChattingFilterRoute +import com.sixkids.teacher.manageclass.invite.ClassInviteRoute +import com.sixkids.teacher.manageclass.main.ManageClassMainRoute +import com.sixkids.teacher.manageclass.setting.ClassSettingRoute +import com.sixkids.teacher.manageclass.statistics.StatisticsRoute +import com.sixkids.ui.SnackbarToken + +fun NavController.navigateManageClass(navOptions: NavOptions) { + navigate(ManageClassRoute.defaultRoute,navOptions) +} + +fun NavController.navigateChattingFilter() { + navigate(ManageClassRoute.chattingFilterRoute) +} + +fun NavController.navigateInvite() { + navigate(ManageClassRoute.inviteRoute) +} + +fun NavController.navigateClassSetting() { + navigate(ManageClassRoute.settingRoute) +} + +fun NavController.navigateStatistics(){ + navigate(ManageClassRoute.summaryRoute) +} + +fun NavGraphBuilder.manageClassNavGraph( + padding: PaddingValues, + onShowSnackBar: (SnackbarToken) -> Unit, + navigateToClassSummary: () -> Unit, + navigateToClassSetting: () -> Unit, + navigateToChattingFilter: () -> Unit, + navigateToInvite: () -> Unit, + navigateBack: () -> Unit +) { + composable(route = ManageClassRoute.defaultRoute){ + ManageClassMainRoute( + padding = padding, + navigateToClassSummary = navigateToClassSummary, + navigateToClassSetting = navigateToClassSetting, + navigateToChattingFilter = navigateToChattingFilter, + navigateToInvite = navigateToInvite + ) + } + + composable(route = ManageClassRoute.chattingFilterRoute){ + ChattingFilterRoute( + onShowSnackBar = onShowSnackBar + ) + } + + composable(ManageClassRoute.inviteRoute){ + ClassInviteRoute( + padding = padding, + onShowSnackBar = onShowSnackBar + ) + } + + composable(ManageClassRoute.settingRoute){ + ClassSettingRoute( + padding = padding, + onShowSnackBar = onShowSnackBar, + navigateBack = navigateBack + ) + } + + composable(ManageClassRoute.summaryRoute){ + StatisticsRoute() + } +} + +object ManageClassRoute { + const val defaultRoute = "manage_class" + const val chattingFilterRoute = "manage_class/chatting_filter" + const val summaryRoute = "manage_class/summary" + const val settingRoute = "manage_class/setting" + const val inviteRoute = "manage_class/invite" +} \ No newline at end of file diff --git a/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/setting/ClassSettingContract.kt b/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/setting/ClassSettingContract.kt new file mode 100644 index 00000000..5ac25013 --- /dev/null +++ b/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/setting/ClassSettingContract.kt @@ -0,0 +1,18 @@ +package com.sixkids.teacher.manageclass.setting + +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState + +sealed interface ClassSettingEffect : SideEffect { + data object navigateBack : ClassSettingEffect + data class onShowSnackBar(val message: String) : ClassSettingEffect +} + + +data class ClassSettingState( + val isLoading: Boolean = false, + val classString: String = "", + val schoolName: String = "", + val grade: Int? = null, + val classNumber: Int? = null, +): UiState \ No newline at end of file diff --git a/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/setting/ClassSettingScreen.kt b/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/setting/ClassSettingScreen.kt new file mode 100644 index 00000000..6243d925 --- /dev/null +++ b/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/setting/ClassSettingScreen.kt @@ -0,0 +1,159 @@ +package com.sixkids.teacher.manageclass.setting + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.sixkids.designsystem.component.appbar.UlbanDetailAppBar +import com.sixkids.designsystem.component.button.UlbanFilledButton +import com.sixkids.designsystem.theme.Red +import com.sixkids.designsystem.theme.RedDark +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.teacher.manageclass.R +import com.sixkids.teacher.manageclass.setting.component.SimpleNumberOutlinedTextField +import com.sixkids.teacher.manageclass.setting.component.SimpleOutlinedTextField +import com.sixkids.ui.SnackbarToken +import com.sixkids.ui.extension.collectWithLifecycle +import com.sixkids.designsystem.R as UlbanRes + +@Composable +fun ClassSettingRoute( + padding: PaddingValues, + viewModel: ClassSettingViewModel = hiltViewModel(), + navigateBack: () -> Unit, + onShowSnackBar: (SnackbarToken) -> Unit +) { + + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + viewModel.sideEffect.collectWithLifecycle { + when (it){ + ClassSettingEffect.navigateBack -> navigateBack() + is ClassSettingEffect.onShowSnackBar -> onShowSnackBar(SnackbarToken(it.message)) + } + } + + LaunchedEffect(Unit) { + viewModel.loadSelectedOrganizationName() + } + + ClassSettingScreen( + classSettingState = uiState, + cancelButtonOnClick = navigateBack, + confirmButtonOnClick = viewModel::updateClassName, + onSchoolTextChange = viewModel::onSchoolNameChanged, + onGradeTextChange = viewModel::onGradeChanged, + onClassNumberTextChange = viewModel::onClassNumberChanged + ) +} + +@Composable +fun ClassSettingScreen( + modifier: Modifier = Modifier, + classSettingState: ClassSettingState = ClassSettingState(), + cancelButtonOnClick: () -> Unit = {}, + confirmButtonOnClick: () -> Unit = {}, + onSchoolTextChange: (String) -> Unit = {}, + onGradeTextChange: (String) -> Unit = {}, + onClassNumberTextChange: (String) -> Unit = {} +) { + val gradeString = classSettingState.grade?.toString() ?: "" + val classNumberString = classSettingState.classNumber?.toString() ?: "" + + Column( + modifier = modifier.fillMaxSize() + ) { + UlbanDetailAppBar( + leftIcon = UlbanRes.drawable.setting, + title = stringResource(id = R.string.manage_class_setting), + content = stringResource(id = R.string.manage_class_setting), + topDescription = "", + bottomDescription = classSettingState.classString, + color = Red + ) + Column( + modifier = Modifier + .fillMaxSize() + .padding(20.dp) + ) { + Spacer(modifier = Modifier.height(20.dp)) + Text( + text = stringResource(id = R.string.setting_class_school_name), + style = UlbanTypography.bodyLarge + ) + Spacer(modifier = Modifier.height(8.dp)) + SimpleOutlinedTextField( + modifier = modifier + .fillMaxWidth() + .padding(4.dp), + text = classSettingState.schoolName, + onTextChange = onSchoolTextChange + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringResource(id = R.string.setting_class_grade), + style = UlbanTypography.bodyLarge + ) + Spacer(modifier = Modifier.height(8.dp)) + SimpleNumberOutlinedTextField( + modifier = modifier + .fillMaxWidth() + .padding(4.dp), + text = gradeString, + postfix = stringResource(id = R.string.setting_class_grade), + onTextChange = {onGradeTextChange(it)} + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringResource(id = R.string.setting_class_class_number), + style = UlbanTypography.bodyLarge + ) + Spacer(modifier = Modifier.height(8.dp)) + SimpleNumberOutlinedTextField( + modifier = modifier + .fillMaxWidth() + .padding(4.dp), + text = classNumberString, + postfix = stringResource(id = R.string.setting_class_class_number), + onTextChange = {onClassNumberTextChange(it)} + ) + Spacer(modifier = Modifier.height(30.dp)) + Row { + UlbanFilledButton( + modifier = Modifier.weight(1f), + text = stringResource(id = R.string.cancel), + onClick = cancelButtonOnClick, + color = Red, + textColor = RedDark + ) + Spacer(modifier = Modifier.width(10.dp)) + UlbanFilledButton( + modifier = Modifier.weight(1f), + text = stringResource(id = R.string.ok), + onClick = confirmButtonOnClick, + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun ClassSettingScreenPreview() { + ClassSettingScreen() +} \ No newline at end of file diff --git a/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/setting/ClassSettingViewModel.kt b/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/setting/ClassSettingViewModel.kt new file mode 100644 index 00000000..65642a2e --- /dev/null +++ b/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/setting/ClassSettingViewModel.kt @@ -0,0 +1,104 @@ +package com.sixkids.teacher.manageclass.setting + +import android.util.Log +import androidx.lifecycle.viewModelScope +import com.sixkids.domain.usecase.organization.GetSelectedOrganizationIdUseCase +import com.sixkids.domain.usecase.organization.LoadSelectedOrganizationNameUseCase +import com.sixkids.domain.usecase.organization.SaveSelectedOrganizationNameUseCase +import com.sixkids.domain.usecase.organization.UpdateClassNameUseCase +import com.sixkids.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + + +private const val TAG = "ClassSettingViewModel_D107" +@HiltViewModel +class ClassSettingViewModel @Inject constructor( + private val getSelectedOrganizationIdUseCase: GetSelectedOrganizationIdUseCase, + private val loadSelectedOrganizationNameUseCase: LoadSelectedOrganizationNameUseCase, + private val saveSelectedOrganizationNameUseCase: SaveSelectedOrganizationNameUseCase, + private val updateClassNameUseCase: UpdateClassNameUseCase +) : BaseViewModel(ClassSettingState()) { + + private var organizationId: Int? = null + fun onSchoolNameChanged(schoolName: String) { intent { copy(schoolName = schoolName) } } + fun onGradeChanged(grade: String) { + intent { copy(grade = if (grade.isBlank()) null else grade.toInt()) } + } + fun onClassNumberChanged(classNumber: String) { + intent { copy(classNumber = if (classNumber.isBlank()) null else classNumber.toInt()) } + } + + fun loadSelectedOrganizationName() { + viewModelScope.launch { + intent { copy(isLoading = true) } + + loadSelectedOrganizationNameUseCase() + .onSuccess { + intent { copy(classString = it.replace("\n"," ")) } + if (it.isNotBlank()){ + separateClassString(it) + } else { + postSideEffect(ClassSettingEffect.onShowSnackBar("학급 정보를 불러오는데 실패했습니다. ;(")) + postSideEffect(ClassSettingEffect.navigateBack) + } + } + .onFailure { + postSideEffect(ClassSettingEffect.onShowSnackBar("학급 정보를 불러오는데 실패했습니다. ;(")) + } + + intent { copy(isLoading = false) } + } + } + + fun updateClassName(){ + if (currentState.schoolName.isBlank() || currentState.grade == null || currentState.classNumber == null) { + postSideEffect(ClassSettingEffect.onShowSnackBar("학급 정보를 입력해주세요.")) + return + } + viewModelScope.launch { + + if (organizationId == null) { + getOrganizationId() + } + + if (organizationId == null) { + Log.d(TAG, "updateClassName: $organizationId") + postSideEffect(ClassSettingEffect.onShowSnackBar("학급 정보를 불러오는데 실패했습니다. ;(")) + return@launch + } else { + val updateClassString = "${currentState.schoolName}\n${currentState.grade}학년 ${currentState.classNumber}반" + updateClassNameUseCase( + organizationId!!, + updateClassString + ).onSuccess { + saveSelectedOrganizationNameUseCase(updateClassString) + postSideEffect(ClassSettingEffect.onShowSnackBar("학급 정보를 업데이트했습니다. :)")) + postSideEffect(ClassSettingEffect.navigateBack) + }.onFailure { + postSideEffect(ClassSettingEffect.onShowSnackBar("학급 정보를 업데이트하는데 실패했습니다. ;(")) + } + } + } + } + + private fun separateClassString(classString: String){ + Log.d(TAG, "separateClassString: $classString") + var school_name = "" + var grade = 0 + var classNumber = 0 + school_name = classString.split("\n")[0] + grade = classString.split("\n")[1].split("학년")[0].toInt() + classNumber = classString.split("\n")[1].split(" ")[1].split("반")[0].toInt() + + intent { copy(schoolName = school_name, grade = grade, classNumber = classNumber) } + } + + private suspend fun getOrganizationId(){ + getSelectedOrganizationIdUseCase() + .onSuccess { + organizationId = it + } + } +} \ No newline at end of file diff --git a/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/setting/component/SimpleOutlinedTextField.kt b/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/setting/component/SimpleOutlinedTextField.kt new file mode 100644 index 00000000..3e15f5e7 --- /dev/null +++ b/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/setting/component/SimpleOutlinedTextField.kt @@ -0,0 +1,68 @@ +package com.sixkids.teacher.manageclass.setting.component + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +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 com.sixkids.designsystem.component.textfield.UlbanBasicTextField +import com.sixkids.designsystem.component.textfield.UlbanNumberTextField + +@Composable +fun SimpleOutlinedTextField( + modifier: Modifier = Modifier, + text : String = "", + hint: String = "", + onTextChange: (String) -> Unit = {} +) { + Card( + modifier = Modifier.border(1.dp, Color.Gray, RoundedCornerShape(6.dp)), + colors = CardDefaults.cardColors( + containerColor = Color.Transparent + ) + ) { + UlbanBasicTextField( + modifier = modifier, + text = text, + hint = hint, + onTextChange = onTextChange, + ) + } +} + +@Composable +fun SimpleNumberOutlinedTextField( + modifier: Modifier = Modifier, + text : String = "", + hint: String = "", + postfix: String = "", + onTextChange: (String) -> Unit = {} +) { + Card( + modifier = Modifier.border(1.dp, Color.Gray, RoundedCornerShape(6.dp)), + colors = CardDefaults.cardColors( + containerColor = Color.Transparent + ) + ) { + UlbanNumberTextField( + modifier = modifier, + text = text, + hint = hint, + onTextChange = onTextChange, + postfix = postfix + ) + } +} + +@Preview(showBackground = true) +@Composable +fun SimpleOutlinedTextFieldPreview() { + SimpleOutlinedTextField( + text = "Hello", + ) +} \ No newline at end of file diff --git a/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/statistics/StatisticsContract.kt b/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/statistics/StatisticsContract.kt new file mode 100644 index 00000000..21cf9d36 --- /dev/null +++ b/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/statistics/StatisticsContract.kt @@ -0,0 +1,15 @@ +package com.sixkids.teacher.manageclass.statistics + +import com.sixkids.model.ClassSummary +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState + +data class StatisticsState( + val isLoading: Boolean = false, + val organizationName: String = "", + val statistics: ClassSummary = ClassSummary() +) : UiState + +sealed interface StatisticsEffect : SideEffect{ + data class HandleException(val throwable: Throwable, val retry: () -> Unit) : StatisticsEffect +} \ No newline at end of file diff --git a/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/statistics/StatisticsScreen.kt b/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/statistics/StatisticsScreen.kt new file mode 100644 index 00000000..51e29238 --- /dev/null +++ b/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/statistics/StatisticsScreen.kt @@ -0,0 +1,171 @@ +package com.sixkids.teacher.manageclass.statistics + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.layout.Arrangement +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil.compose.AsyncImage +import com.sixkids.designsystem.component.appbar.UlbanDetailAppBar +import com.sixkids.designsystem.component.item.StudentSimpleCardItem +import com.sixkids.designsystem.theme.Blue +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.model.MemberSimpleClassSummary +import com.sixkids.teacher.manageclass.R +import kotlin.math.max +import kotlin.math.min +import com.sixkids.designsystem.R as DesignSystemR + +@Composable +fun StatisticsRoute( + viewModel: StatisticsViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(key1 = Unit) { + viewModel.initData() + } + + StatisticsScreen( + uiState = uiState + ) +} + +@Composable +fun StatisticsScreen( + modifier: Modifier = Modifier, + uiState: StatisticsState = StatisticsState(), +) { + val scrollState = rememberScrollState() + val isScrolled by remember { + derivedStateOf { + scrollState.value > 100 + } + } + + Column( + modifier = modifier.fillMaxSize(), + ) { + UlbanDetailAppBar( + leftIcon = DesignSystemR.drawable.statistics, + title = stringResource(id = R.string.manage_class_statistics), + content = stringResource(id = R.string.manage_class_statistics), + topDescription = "", + bottomDescription = uiState.organizationName, + color = Blue, + expanded = !isScrolled, + ) + Column( + modifier = Modifier + .fillMaxSize() + .padding(20.dp).verticalScroll(scrollState) + ) { + BestWorstStudent( + studentList = uiState.statistics.challengeCounts, + title = stringResource(id = R.string.statistics_challenge), + icon = DesignSystemR.drawable.hifive + ) + + BestWorstStudent( + studentList = uiState.statistics.relayCounts, + title = stringResource(id = R.string.statistics_relay), + icon = DesignSystemR.drawable.relay + ) + + BestWorstStudent( + studentList = uiState.statistics.postsCounts, + title = stringResource(id = R.string.statistics_post), + icon = DesignSystemR.drawable.board + ) + } + } +} + +@Composable +fun BestWorstStudent( + studentList: List = emptyList(), + title: String = "", + @DrawableRes icon: Int, + +){ + Spacer(modifier = Modifier.height(20.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + AsyncImage( + model = icon, + contentDescription = "relay", + modifier = Modifier + .padding(end = 10.dp) + .size(50.dp) + ) + Text(text = title, style = UlbanTypography.titleSmall) + } + + + if (studentList.isNotEmpty()) { + Text( + text = "가장 많이 참여한 학생", + style = UlbanTypography.bodyMedium, + modifier = Modifier.padding(vertical = 10.dp) + ) + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + for (i in 0 until min(3,studentList.size )) { + StudentSimpleCardItem( + modifier = Modifier.weight(1f), + id = studentList[i].member.id, + name = studentList[i].member.name, + photo = studentList[i].member.photo, + score = studentList[i].count, + isCount = true + ) + } + } + + Text( + text = "가장 적게 참여한 학생", + style = UlbanTypography.bodyMedium, + modifier = Modifier.padding(vertical = 10.dp) + ) + + val startIdx = max(studentList.size - 1, 0) + val endIdx = max(studentList.size - 3, 0) + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + for (i in startIdx downTo endIdx) { + StudentSimpleCardItem( + modifier = Modifier.weight(1f), + id = studentList[i].member.id, + name = studentList[i].member.name, + photo = studentList[i].member.photo, + score = studentList[i].count, + isCount = true + ) + } + } + } +} + +@Composable +@Preview(showBackground = true) +fun StatisticsScreenPreview() { + StatisticsScreen() +} \ No newline at end of file diff --git a/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/statistics/StatisticsViewModel.kt b/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/statistics/StatisticsViewModel.kt new file mode 100644 index 00000000..bf2d7a72 --- /dev/null +++ b/android/feature/teacher/manageclass/src/main/java/com/sixkids/teacher/manageclass/statistics/StatisticsViewModel.kt @@ -0,0 +1,33 @@ +package com.sixkids.teacher.manageclass.statistics + +import android.util.Log +import androidx.lifecycle.viewModelScope +import com.sixkids.domain.usecase.organization.GetOrganizationSummaryUseCase +import com.sixkids.domain.usecase.organization.GetSelectedOrganizationIdUseCase +import com.sixkids.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +private const val TAG = "D107" +@HiltViewModel +class StatisticsViewModel @Inject constructor( + private val getStatisticsUseCase: GetOrganizationSummaryUseCase, + private val getSelectedOrganizationIdUseCase: GetSelectedOrganizationIdUseCase +) : BaseViewModel(StatisticsState()){ + + fun initData(){ + viewModelScope.launch { + getSelectedOrganizationIdUseCase().onSuccess { + getStatisticsUseCase(it).onSuccess { + Log.d(TAG, "initData: $it") + intent { copy(statistics = it) } + }.onFailure { + Log.d(TAG, "initData2: $it") + } + }.onFailure { + postSideEffect(StatisticsEffect.HandleException(it, ::initData)) + } + } + } +} \ No newline at end of file diff --git a/android/feature/teacher/manageclass/src/main/res/values/strings.xml b/android/feature/teacher/manageclass/src/main/res/values/strings.xml new file mode 100644 index 00000000..3d5ecd66 --- /dev/null +++ b/android/feature/teacher/manageclass/src/main/res/values/strings.xml @@ -0,0 +1,28 @@ + + + 취소 + 확인 + + 학급관리 + 학급 통계 + 학급설정 + 채팅필터링 + 학생초대 + + 필터링 단어 + + 초대 코드 생성 + 초대 코드가 발급됐어요 + 10분뒤 만료됩니다 + + 학교 이름 + 학년 + + + 함께 달리기 + 이어 달리기 + 게시글 작성 + + 가장 많이 참여한 학생 + 가장 적게 참여한 학생 + \ No newline at end of file diff --git a/android/feature/teacher/manageclass/src/test/java/com/sixkids/teacher/manageclass/ExampleUnitTest.kt b/android/feature/teacher/manageclass/src/test/java/com/sixkids/teacher/manageclass/ExampleUnitTest.kt index df8c4c5f..6b8e1963 100644 --- a/android/feature/teacher/manageclass/src/test/java/com/sixkids/teacher/manageclass/ExampleUnitTest.kt +++ b/android/feature/teacher/manageclass/src/test/java/com/sixkids/teacher/manageclass/ExampleUnitTest.kt @@ -14,4 +14,21 @@ class ExampleUnitTest { fun addition_isCorrect() { assertEquals(4, 2 + 2) } + + @Test + fun `학급_문자열__파싱_테스트`(){ + //when + val text = "구미 초등학교\n2학년 3반" + + + val school_name = text.split("\n")[0] + val grade = text.split("\n")[1].split("학년")[0].toInt() + val classNumber = text.split("\n")[1].split(" ")[1].split("반")[0].toInt() + + //then + assertEquals("구미 초등학교", school_name) + assertEquals(2, grade) + assertEquals(3, classNumber) + + } } \ No newline at end of file diff --git a/android/feature/teacher/managestudent/build.gradle.kts b/android/feature/teacher/managestudent/build.gradle.kts index 114a4915..9c66bef9 100644 --- a/android/feature/teacher/managestudent/build.gradle.kts +++ b/android/feature/teacher/managestudent/build.gradle.kts @@ -7,4 +7,6 @@ android { } dependencies { + implementation(projects.core.designsystem) + implementation(libs.accompanist.pager) } diff --git a/android/feature/teacher/managestudent/src/main/java/com/sixkids/teacher/managestudent/detail/ManageStudentDetailContract.kt b/android/feature/teacher/managestudent/src/main/java/com/sixkids/teacher/managestudent/detail/ManageStudentDetailContract.kt new file mode 100644 index 00000000..e8dbc399 --- /dev/null +++ b/android/feature/teacher/managestudent/src/main/java/com/sixkids/teacher/managestudent/detail/ManageStudentDetailContract.kt @@ -0,0 +1,21 @@ +package com.sixkids.teacher.managestudent.detail + +import com.sixkids.model.MemberDetail +import com.sixkids.model.MemberSimpleWithScore +import com.sixkids.model.StudentRelation +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState + +data class ManageStudentDetailState( + val memberDetail: MemberDetail = MemberDetail(), + val studentList: List = emptyList(), + val relation: StudentRelation = StudentRelation(), + val isDialogShowing: Boolean = false, +): UiState + +sealed interface ManageStudentDetailEffect : SideEffect{ + data class NavigateToChallenge(val studentId: Long) : ManageStudentDetailEffect + data class NavigateToRelay(val studentId: Long) : ManageStudentDetailEffect + data class NavigateToPost(val studentId: Long) : ManageStudentDetailEffect + data class HandleException(val throwable: Throwable, val retry: () -> Unit) : ManageStudentDetailEffect +} \ No newline at end of file diff --git a/android/feature/teacher/managestudent/src/main/java/com/sixkids/teacher/managestudent/detail/ManageStudentDetailScreen.kt b/android/feature/teacher/managestudent/src/main/java/com/sixkids/teacher/managestudent/detail/ManageStudentDetailScreen.kt new file mode 100644 index 00000000..133c7234 --- /dev/null +++ b/android/feature/teacher/managestudent/src/main/java/com/sixkids/teacher/managestudent/detail/ManageStudentDetailScreen.kt @@ -0,0 +1,333 @@ +package com.sixkids.teacher.managestudent.detail + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +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.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil.compose.AsyncImage +import com.sixkids.designsystem.component.item.StudentSimpleCardItem +import com.sixkids.designsystem.theme.Blue +import com.sixkids.designsystem.theme.Orange +import com.sixkids.designsystem.theme.Purple +import com.sixkids.designsystem.theme.Red +import com.sixkids.designsystem.theme.UlbanTheme +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.model.MemberSimpleWithScore +import com.sixkids.teacher.managestudent.detail.component.MemberRelationDialog +import com.sixkids.designsystem.R as DesignR + +@Composable +fun ManageStudentDetailRoute( + viewModel: ManageStudentDetailViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(key1 = Unit) { + viewModel.initData() + } + + ManageStudentDetailScreen( + uiState = uiState, + showDialog = viewModel::onFriendClick, + cancelDialog = viewModel::cancelDialog + ) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ManageStudentDetailScreen( + uiState: ManageStudentDetailState = ManageStudentDetailState(), + showDialog: (Long) -> Unit = {}, + cancelDialog : () -> Unit = {} +) { + val pagerState = + rememberPagerState(pageCount = { + if (uiState.studentList.size % 3 == 0) uiState.studentList.size / 3 + else uiState.studentList.size / 3 + 1 + }) + Box( + modifier = Modifier + .fillMaxSize() + .padding(20.dp) + ) { + Column(modifier = Modifier.padding(top = 20.dp)) { + UserInfoSection( + photo = uiState.memberDetail.photo, + name = uiState.memberDetail.name, + exp = uiState.memberDetail.exp + ) + + Spacer(modifier = Modifier.size(20.dp)) + + Text( + text = "인싸 랭킹 ${uiState.memberDetail.isolationRank}등", + style = UlbanTypography.titleSmall, + modifier = Modifier.padding(start = 10.dp) + ) + + Spacer(modifier = Modifier.size(20.dp)) + + Text( + text = "${uiState.memberDetail.name} 학생과 가장 친한 친구들", + style = UlbanTypography.bodyMedium + ) + Spacer(modifier = Modifier.size(5.dp)) + + BestFriendsSection( + pagerState = pagerState, + uiState.studentList, + onFriendClick = showDialog + ) + + Spacer(modifier = Modifier.size(20.dp)) + + Text( + text = "활동 내역", style = UlbanTypography.titleSmall, + modifier = Modifier.padding(start = 10.dp) + ) + + Spacer(modifier = Modifier.size(5.dp)) + + StatisticsSection( + challenge = uiState.memberDetail.challengeCount, + relay = uiState.memberDetail.relayCount, + post = uiState.memberDetail.postCount + ) + } + + if (uiState.isDialogShowing) { + MemberRelationDialog( + confirmButtonOnClick = cancelDialog, + relationInfo = uiState.relation + ) + } + } +} + +@Composable +fun UserInfoSection( + photo: String, + name: String, + exp: Int +) { + Row(verticalAlignment = Alignment.CenterVertically) { + AsyncImage( + model = photo, contentDescription = "profile", + modifier = Modifier + .size(68.dp) + .clip(RoundedCornerShape(16.dp)), + contentScale = ContentScale.Crop + ) + + Spacer(modifier = Modifier.size(20.dp)) + + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.Center) { + Text(text = name, style = UlbanTypography.titleMedium) + Spacer(modifier = Modifier.size(10.dp)) + Text(text = "$exp exp", style = UlbanTypography.titleSmall) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun BestFriendsSection( + pagerState: PagerState, + bestFriends: List, + onFriendClick: (Long) -> Unit +) { + + val itemSpacing = 16.dp + + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxWidth() + ) { page -> + val startIndex = page * 3 + val endIndex = minOf(startIndex + 3, bestFriends.size) + Row( + horizontalArrangement = Arrangement.spacedBy(itemSpacing), + modifier = Modifier.fillMaxWidth() + ) { + for (i in startIndex until endIndex) { + val item = bestFriends[i] + StudentSimpleCardItem( + id = item.memberSimple.id, + name = item.memberSimple.name, + photo = item.memberSimple.photo, + score = item.relationPoint, + onClick = onFriendClick, + modifier = Modifier.weight(1f) + ) + } + + if (page == (bestFriends.size + 2) / 3 - 1) { + for (i in endIndex until startIndex + 3) { + Spacer(modifier = Modifier.weight(1f)) + } + } + } + } +} + +@Composable +fun StatisticsSection( + challenge: Int, + relay: Int, + post: Int +) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + StatisticsCard( + Red, + image = DesignR.drawable.hifive, + title = "함께 달리기", + count = "${challenge}회 참여" + ) + StatisticsCard( + Orange, + image = DesignR.drawable.relay, + title = "이어 달리기", + count = "${relay}회 참여" + ) + } + + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + StatisticsCard( + Blue, + image = DesignR.drawable.board, + title = "게시글", + count = "${post}개 작성" + ) + + + val screenWidthDp = with(LocalDensity.current) { + LocalContext.current.resources.displayMetrics.widthPixels.toDp() + } + + Box( + modifier = Modifier + .size(screenWidthDp / 3 + screenWidthDp / 10) + .aspectRatio(1f) + .clip(RoundedCornerShape(16.dp)) + .background(Purple) + + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(10.dp), + ) { + + Text( + text = "내보내기", + style = UlbanTypography.titleSmall, + modifier = Modifier + .padding(vertical = 10.dp) + .padding(top = 10.dp, start = 15.dp) + ) + + AsyncImage( + model = DesignR.drawable.quit, + placeholder = painterResource(id = DesignR.drawable.quit), + contentDescription = "img", + modifier = Modifier + .size(200.dp) + .align(Alignment.End) + .offset(x = 40.dp, y = -10.dp) + ) + + } + } + } + } + +} + +@Composable +fun StatisticsCard( + color: Color = Red, + @DrawableRes image: Int, + title: String, + count: String +) { + val screenWidthDp = with(LocalDensity.current) { + LocalContext.current.resources.displayMetrics.widthPixels.toDp() + } + + Box( + modifier = Modifier + .size(screenWidthDp / 3 + screenWidthDp / 10) + .aspectRatio(1f) + .clip(RoundedCornerShape(16.dp)) + .background(color) + .padding(bottom = 10.dp) + + ) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + AsyncImage( + model = image, + contentDescription = "img", + modifier = Modifier.size(100.dp) + ) + Text( + text = title, + style = UlbanTypography.titleSmall, + modifier = Modifier.padding(vertical = 10.dp) + ) + + Text(text = count, style = UlbanTypography.titleSmall) + } + } +} + +@Composable +@Preview(showBackground = true) +fun ManageStudentDetailScreenPreview() { + UlbanTheme { + ManageStudentDetailScreen() + } +} \ No newline at end of file diff --git a/android/feature/teacher/managestudent/src/main/java/com/sixkids/teacher/managestudent/detail/ManageStudentDetailViewModel.kt b/android/feature/teacher/managestudent/src/main/java/com/sixkids/teacher/managestudent/detail/ManageStudentDetailViewModel.kt new file mode 100644 index 00000000..f1812f62 --- /dev/null +++ b/android/feature/teacher/managestudent/src/main/java/com/sixkids/teacher/managestudent/detail/ManageStudentDetailViewModel.kt @@ -0,0 +1,84 @@ +package com.sixkids.teacher.managestudent.detail + +import android.util.Log +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.sixkids.domain.usecase.organization.GetMemberRelationUseCase +import com.sixkids.domain.usecase.organization.GetSelectedOrganizationIdUseCase +import com.sixkids.domain.usecase.organization.GetStudentDetailUseCase +import com.sixkids.domain.usecase.organization.GetStudentRelationUseCase +import com.sixkids.teacher.managestudent.navigation.ManageStudentRoute.STUDENT_ID_NAME +import com.sixkids.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +private const val TAG = "D107" +@HiltViewModel +class ManageStudentDetailViewModel @Inject constructor( + private val getSelectedOrganizationIdUseCase: GetSelectedOrganizationIdUseCase, + private val getStudentDetailUseCase: GetStudentDetailUseCase, + private val getMemberRelationUseCase: GetMemberRelationUseCase, + private val getStudentRelationUseCase: GetStudentRelationUseCase, + savedStateHandle: SavedStateHandle +) : BaseViewModel(ManageStudentDetailState()){ + private val studentId = savedStateHandle.get(STUDENT_ID_NAME) + private var orgId = -1 + + fun initData(){ + viewModelScope.launch { + getSelectedOrganizationIdUseCase().onSuccess { + orgId= it + getDetail(it) + getRelation(it) + }.onFailure { + postSideEffect( + ManageStudentDetailEffect.HandleException(it, ::initData) + ) + } + } + } + private fun getDetail(orgId: Int){ + viewModelScope.launch { + getStudentDetailUseCase(orgId.toLong(), studentId!!).onSuccess {member -> + intent { copy(memberDetail = member) } + }.onFailure { + postSideEffect( + ManageStudentDetailEffect.HandleException(it, ::initData) + ) + } + } + } + private fun getRelation(orgId: Int){ + viewModelScope.launch { + getMemberRelationUseCase(orgId.toLong(), studentId!!, null).onSuccess {relationList -> + intent { copy(studentList = relationList) } + }.onFailure { + postSideEffect( + ManageStudentDetailEffect.HandleException(it, ::initData) + ) + } + } + } + + fun onFriendClick(targetStudentId: Long) { + Log.d(TAG, "onFriendClick: $targetStudentId, $studentId") + viewModelScope.launch { + getStudentRelationUseCase(orgId.toLong(), studentId!!, targetStudentId) + .onSuccess { + Log.d(TAG, "onFriendClick: $it") + intent { + copy( + isDialogShowing = true, + relation = it + ) + } + }.onFailure { + //todo + } + } + } + + fun cancelDialog() = intent { copy(isDialogShowing = false) } + +} \ No newline at end of file diff --git a/android/feature/teacher/managestudent/src/main/java/com/sixkids/teacher/managestudent/detail/component/MemberRelationDialog.kt b/android/feature/teacher/managestudent/src/main/java/com/sixkids/teacher/managestudent/detail/component/MemberRelationDialog.kt new file mode 100644 index 00000000..9727800a --- /dev/null +++ b/android/feature/teacher/managestudent/src/main/java/com/sixkids/teacher/managestudent/detail/component/MemberRelationDialog.kt @@ -0,0 +1,70 @@ +package com.sixkids.teacher.managestudent.detail.component + +import androidx.compose.foundation.layout.Arrangement +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.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.sixkids.designsystem.component.button.UlbanFilledButton +import com.sixkids.designsystem.component.dialog.UlbanBasicDialog +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.model.StudentRelation + +@Composable +fun MemberRelationDialog( + confirmButtonOnClick: () -> Unit = {}, + relationInfo: StudentRelation = StudentRelation() +) { + UlbanBasicDialog { + Column( + modifier = Modifier.width(280.dp).padding(vertical = 16.dp, horizontal = 10.dp), + verticalArrangement = Arrangement.spacedBy(15.dp) + ) { + Text( + text = "${relationInfo.name} 학생과의 기록", + style = UlbanTypography.titleMedium + ) + + Spacer(modifier = Modifier.height(10.dp)) + + Text( + text = "점수 ${relationInfo.relationPoint}점", + style = UlbanTypography.bodyMedium + ) + Text( + text = "태그 인사 횟수 ${relationInfo.tagGreetingCount}회", + style = UlbanTypography.bodyMedium + ) + Text( + text = "그룹 횟수 ${relationInfo.groupCount}회", + style = UlbanTypography.bodyMedium + ) + Text( + text = "이어 달리기 받은 횟수 ${relationInfo.receiveCount}회", + style = UlbanTypography.bodyMedium + ) + Text( + text = "이어 달리기 전달 횟수 ${relationInfo.sendCount}회", + style = UlbanTypography.bodyMedium + ) + + UlbanFilledButton( + modifier = Modifier.fillMaxWidth(), + text = "확인", + onClick = { confirmButtonOnClick()}) + } + } +} + +@Composable +@Preview(showBackground = true) +fun MemberRelationDialogPreview() { + MemberRelationDialog() +} \ No newline at end of file diff --git a/android/feature/teacher/managestudent/src/main/java/com/sixkids/teacher/managestudent/main/ManageStudentMainContract.kt b/android/feature/teacher/managestudent/src/main/java/com/sixkids/teacher/managestudent/main/ManageStudentMainContract.kt new file mode 100644 index 00000000..3e5f15a9 --- /dev/null +++ b/android/feature/teacher/managestudent/src/main/java/com/sixkids/teacher/managestudent/main/ManageStudentMainContract.kt @@ -0,0 +1,18 @@ +package com.sixkids.teacher.managestudent.main + +import com.sixkids.model.MemberSimple +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState + +data class ManageStudentMainState( + val isLoading: Boolean = false, + val classString: String = "", + val organizationId: Int = 0, + val studentList: List = emptyList(), +): UiState + +sealed interface ManageStudentMainEffect : SideEffect{ + data class NavigateToStudentDetail(val studentId: Long) : ManageStudentMainEffect + + data class HandleException(val throwable: Throwable, val retry: () -> Unit) : ManageStudentMainEffect +} \ No newline at end of file diff --git a/android/feature/teacher/managestudent/src/main/java/com/sixkids/teacher/managestudent/main/ManageStudentMainScreen.kt b/android/feature/teacher/managestudent/src/main/java/com/sixkids/teacher/managestudent/main/ManageStudentMainScreen.kt new file mode 100644 index 00000000..b9d710e2 --- /dev/null +++ b/android/feature/teacher/managestudent/src/main/java/com/sixkids/teacher/managestudent/main/ManageStudentMainScreen.kt @@ -0,0 +1,115 @@ +package com.sixkids.teacher.managestudent.main + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.sixkids.designsystem.component.item.StudentSimpleCardItem +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.model.MemberSimple +import com.sixkids.teacher.managestudent.R +import com.sixkids.ui.extension.collectWithLifecycle +import com.sixkids.designsystem.R as UlbanRes + + +@Composable +fun ManageStudentMainRoute( + padding: PaddingValues, + viewModel: ManageStudentMainViewModel = hiltViewModel(), + navigateToStudentDetail: (Long) -> Unit, + handleException: (Throwable, () -> Unit) -> Unit +) { + + val uiState = viewModel.uiState.collectAsStateWithLifecycle().value + + LaunchedEffect(key1 = viewModel) { + viewModel.initData() + } + + viewModel.sideEffect.collectWithLifecycle { + when (it) { + is ManageStudentMainEffect.NavigateToStudentDetail -> { + navigateToStudentDetail(it.studentId) + } + is ManageStudentMainEffect.HandleException -> handleException(it.throwable, it.retry) + } + } + + Box( + modifier = Modifier.padding(padding) + ) { + ManageStudentMainScreen( + uiState = uiState, + navigateToStudentDetail = navigateToStudentDetail + ) + } +} + +@Composable +fun ManageStudentMainScreen( + modifier: Modifier = Modifier, + uiState: ManageStudentMainState = ManageStudentMainState(), + navigateToStudentDetail: (Long) -> Unit = {}, +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(20.dp) + ) { + Spacer(modifier = Modifier.height(20.dp)) + Text( + text = stringResource(id = R.string.manage_student_title), + style = UlbanTypography.titleLarge, + modifier = Modifier.padding(bottom = 10.dp) + ) + Text( + text = uiState.classString.replace("\n", " "), + style = UlbanTypography.titleSmall + ) + Spacer(modifier = Modifier.height(20.dp)) + + // 학생 리스트 + Spacer(modifier = Modifier.height(10.dp)) + LazyVerticalGrid( + columns = GridCells.Fixed(3), + ) { + items(uiState.studentList.size) { + StudentSimpleCardItem( + id = uiState.studentList[it].id, + modifier = Modifier.padding(4.dp), + name = uiState.studentList[it].name, + photo = uiState.studentList[it].photo, + onClick = navigateToStudentDetail + ) + } + } + } +} + + +@Preview(showBackground = true) +@Composable +fun ManageStudentMainScreenPreview() { + ManageStudentMainScreen() +} \ No newline at end of file diff --git a/android/feature/teacher/managestudent/src/main/java/com/sixkids/teacher/managestudent/main/ManageStudentMainViewModel.kt b/android/feature/teacher/managestudent/src/main/java/com/sixkids/teacher/managestudent/main/ManageStudentMainViewModel.kt new file mode 100644 index 00000000..3be7fde4 --- /dev/null +++ b/android/feature/teacher/managestudent/src/main/java/com/sixkids/teacher/managestudent/main/ManageStudentMainViewModel.kt @@ -0,0 +1,38 @@ +package com.sixkids.teacher.managestudent.main + +import androidx.lifecycle.viewModelScope +import com.sixkids.domain.usecase.organization.GetOrganizationMemberUseCase +import com.sixkids.domain.usecase.organization.GetSelectedOrganizationIdUseCase +import com.sixkids.domain.usecase.organization.LoadSelectedOrganizationNameUseCase +import com.sixkids.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ManageStudentMainViewModel @Inject constructor( + private val getSelectedOrganizationIdUseCase: GetSelectedOrganizationIdUseCase, + private val getOrganizationMemberUseCase: GetOrganizationMemberUseCase, + private val loadSelectedOrganizationNameUseCase: LoadSelectedOrganizationNameUseCase +): BaseViewModel(ManageStudentMainState()){ + fun initData(){ + viewModelScope.launch { + loadSelectedOrganizationNameUseCase().onSuccess { + intent { copy(classString = it) } + }.onFailure { + intent { copy(classString = "") } + } + + getSelectedOrganizationIdUseCase().onSuccess { + getOrganizationMemberUseCase(it) + .onSuccess { + intent { copy(studentList = it) } + }.onFailure { + postSideEffect(ManageStudentMainEffect.HandleException(it, ::initData)) + } + }.onFailure { + postSideEffect(ManageStudentMainEffect.HandleException(it, ::initData)) + } + } + } +} \ No newline at end of file diff --git a/android/feature/teacher/managestudent/src/main/java/com/sixkids/teacher/managestudent/navigation/ManageStudentNavigation.kt b/android/feature/teacher/managestudent/src/main/java/com/sixkids/teacher/managestudent/navigation/ManageStudentNavigation.kt new file mode 100644 index 00000000..87c82c31 --- /dev/null +++ b/android/feature/teacher/managestudent/src/main/java/com/sixkids/teacher/managestudent/navigation/ManageStudentNavigation.kt @@ -0,0 +1,55 @@ +package com.sixkids.teacher.managestudent.navigation + +import androidx.compose.foundation.layout.PaddingValues +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.NavType +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import com.sixkids.teacher.managestudent.detail.ManageStudentDetailEffect +import com.sixkids.teacher.managestudent.detail.ManageStudentDetailRoute +import com.sixkids.teacher.managestudent.main.ManageStudentMainEffect +import com.sixkids.teacher.managestudent.main.ManageStudentMainRoute +import com.sixkids.teacher.managestudent.navigation.ManageStudentRoute.STUDENT_ID_NAME + +fun NavController.navigateManageStudent(navOptions: NavOptions) { + navigate(ManageStudentRoute.defaultRoute,navOptions) +} + +fun NavController.navigateStudentDetail(studentId: Long) { + navigate(ManageStudentRoute.studentDetailRoute(studentId)) +} + +fun NavGraphBuilder.manageStudentNavGraph( + padding: PaddingValues, + navigateToStudentDetail: (Long) -> Unit, + handleException: (Throwable, () -> Unit) -> Unit +) { + composable(route = ManageStudentRoute.defaultRoute) { + ManageStudentMainRoute( + padding, + navigateToStudentDetail = navigateToStudentDetail, + handleException = handleException + ) + } + + composable(route = ManageStudentRoute.studentDetailRoute, + arguments = listOf( + navArgument(STUDENT_ID_NAME) { type = NavType.LongType }, + )) + { + ManageStudentDetailRoute( + + ) + } +} + +object ManageStudentRoute { + const val STUDENT_ID_NAME = "studentId" + + const val defaultRoute = "manage_student" + const val studentDetailRoute = "student-detail?studentId={$STUDENT_ID_NAME}" + + fun studentDetailRoute(relayId: Long) = "student-detail?studentId=$relayId" +} \ No newline at end of file diff --git a/android/feature/teacher/managestudent/src/main/res/values/strings.xml b/android/feature/teacher/managestudent/src/main/res/values/strings.xml new file mode 100644 index 00000000..c4d91e2b --- /dev/null +++ b/android/feature/teacher/managestudent/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + + 학생관리 + 이름순 + \ No newline at end of file diff --git a/android/feature/teacher/relay/.gitignore b/android/feature/teacher/relay/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/android/feature/teacher/relay/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/android/feature/teacher/relay/build.gradle.kts b/android/feature/teacher/relay/build.gradle.kts new file mode 100644 index 00000000..a4df7f54 --- /dev/null +++ b/android/feature/teacher/relay/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + alias(libs.plugins.sixkids.android.feature.compose) +} + +android { + namespace = "com.sixkids.teacher.relay" +} + +dependencies { + implementation(libs.bundles.paging) + + implementation(libs.kotlinx.serialization.json) + + implementation(projects.core.nfc) +} diff --git a/android/feature/teacher/relay/consumer-rules.pro b/android/feature/teacher/relay/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/android/feature/teacher/relay/proguard-rules.pro b/android/feature/teacher/relay/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/android/feature/teacher/relay/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/android/feature/teacher/relay/src/androidTest/java/com/sixkids/teacher/relay/ExampleInstrumentedTest.kt b/android/feature/teacher/relay/src/androidTest/java/com/sixkids/teacher/relay/ExampleInstrumentedTest.kt new file mode 100644 index 00000000..d4e1d427 --- /dev/null +++ b/android/feature/teacher/relay/src/androidTest/java/com/sixkids/teacher/relay/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.sixkids.teacher.relay + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.sixkids.teacher.relay.test", appContext.packageName) + } +} \ No newline at end of file diff --git a/android/feature/teacher/relay/src/main/AndroidManifest.xml b/android/feature/teacher/relay/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a5918e68 --- /dev/null +++ b/android/feature/teacher/relay/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/android/feature/teacher/relay/src/main/java/com/sixkids/teacher/relay/detail/RelayDetailContract.kt b/android/feature/teacher/relay/src/main/java/com/sixkids/teacher/relay/detail/RelayDetailContract.kt new file mode 100644 index 00000000..9568ebdf --- /dev/null +++ b/android/feature/teacher/relay/src/main/java/com/sixkids/teacher/relay/detail/RelayDetailContract.kt @@ -0,0 +1,15 @@ +package com.sixkids.teacher.relay.detail + +import com.sixkids.model.RelayDetail +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState + +data class RelayDetailState( + val isLoading: Boolean = false, + val relayDetail: RelayDetail = RelayDetail(), +) : UiState + +sealed interface RelayDetailSideEffect : SideEffect{ + data class HandleException(val throwable: Throwable, val retry: () -> Unit) : + RelayDetailSideEffect +} \ No newline at end of file diff --git a/android/feature/teacher/relay/src/main/java/com/sixkids/teacher/relay/detail/RelayDetailScreen.kt b/android/feature/teacher/relay/src/main/java/com/sixkids/teacher/relay/detail/RelayDetailScreen.kt new file mode 100644 index 00000000..e1baf431 --- /dev/null +++ b/android/feature/teacher/relay/src/main/java/com/sixkids/teacher/relay/detail/RelayDetailScreen.kt @@ -0,0 +1,149 @@ +package com.sixkids.teacher.relay.detail + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +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.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.sixkids.designsystem.component.appbar.UlbanDetailAppBar +import com.sixkids.designsystem.component.item.UlbanRunnerItem +import com.sixkids.designsystem.theme.Orange +import com.sixkids.designsystem.theme.UlbanTheme +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.teacher.relay.R +import com.sixkids.ui.extension.collectWithLifecycle +import com.sixkids.ui.util.formatToMonthDayTime +import com.sixkids.designsystem.R as DesignSystemR + + +@Composable +fun RelayDetailRoute( + viewModel: RelayDetailViewModel = hiltViewModel(), + handleException: (Throwable, () -> Unit) -> Unit +){ + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + viewModel.sideEffect.collectWithLifecycle { + when (it) { + is RelayDetailSideEffect.HandleException -> handleException(it.throwable, it.retry) + } + } + LaunchedEffect(key1 = Unit) { + viewModel.getRelayDetail() + } + + RelayDetailScreen( + uiState = uiState + ) + +} + +@Composable +fun RelayDetailScreen( + uiState: RelayDetailState = RelayDetailState() +) { + val listState = rememberLazyListState() + val isScrolled by remember { + derivedStateOf { + listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 100 + } + } + + Box(modifier = Modifier.fillMaxSize()) + { + Column( + modifier = Modifier + .fillMaxSize() + ) { + UlbanDetailAppBar( + leftIcon = DesignSystemR.drawable.relay, + title = stringResource(id = R.string.relay), + content = stringResource(id = R.string.relay), + topDescription = "${uiState.relayDetail.startTime.formatToMonthDayTime()} ~ ${uiState.relayDetail.endTime.formatToMonthDayTime()}", + bottomDescription = stringResource( + id = R.string.relay_detail_last_member, + uiState.relayDetail.lastMemberName + ), + color = Orange, + expanded = !isScrolled, + ) + Column( + modifier = Modifier + .fillMaxSize() + .padding(top = 8.dp, start = 16.dp, end = 16.dp, bottom = 16.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + modifier = Modifier + .padding(start = 4.dp) + .size(32.dp), + painter = painterResource(id = DesignSystemR.drawable.member), + tint = Color.Unspecified, + contentDescription = null + ) + Text( + text = stringResource( + id = R.string.relay_detail_total_count, + uiState.relayDetail.lastTurn + ), + style = UlbanTypography.bodyMedium.copy(fontWeight = FontWeight.Bold) + ) + } + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + if (uiState.relayDetail.runnerList.isEmpty()) { + Text( + text = stringResource(id = R.string.relay_no_history), + style = UlbanTypography.bodyMedium.copy(fontWeight = FontWeight.Bold) + ) + } else { + LazyColumn( + state = listState, + ) { + items(uiState.relayDetail.runnerList) { runner -> + UlbanRunnerItem( + memberPhoto = runner.memberPhoto, + memberName = runner.memberName, + time = runner.time, + question = runner.question, + isLastTurn = runner.endStatus + ) + } + } + } + } + } + } +} + +@Composable +@Preview(showBackground = true) +fun RelayDetailScreenPreview() { + UlbanTheme { + RelayDetailScreen() + } +} diff --git a/android/feature/teacher/relay/src/main/java/com/sixkids/teacher/relay/detail/RelayDetailViewModel.kt b/android/feature/teacher/relay/src/main/java/com/sixkids/teacher/relay/detail/RelayDetailViewModel.kt new file mode 100644 index 00000000..2c202540 --- /dev/null +++ b/android/feature/teacher/relay/src/main/java/com/sixkids/teacher/relay/detail/RelayDetailViewModel.kt @@ -0,0 +1,34 @@ +package com.sixkids.teacher.relay.detail + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.sixkids.domain.usecase.relay.GetRelayDetailUseCase +import com.sixkids.teacher.relay.navigation.RelayRoute.RELAY_ID_NAME +import com.sixkids.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + + +@HiltViewModel +class RelayDetailViewModel @Inject constructor( + private val getRelayDetailUseCase: GetRelayDetailUseCase, + savedStateHandle: SavedStateHandle +) : BaseViewModel(RelayDetailState()) +{ + private val relayId = savedStateHandle.get(RELAY_ID_NAME) + + fun getRelayDetail() { + viewModelScope.launch { + intent { copy(isLoading = true) } + getRelayDetailUseCase(relayId!!) + .onSuccess { relayDetail -> + intent { copy(relayDetail = relayDetail) } + } + .onFailure { exception -> + postSideEffect(RelayDetailSideEffect.HandleException(exception, ::getRelayDetail)) + } + intent { copy(isLoading = false) } + } + } +} \ No newline at end of file diff --git a/android/feature/teacher/relay/src/main/java/com/sixkids/teacher/relay/history/RelayHistoryContract.kt b/android/feature/teacher/relay/src/main/java/com/sixkids/teacher/relay/history/RelayHistoryContract.kt new file mode 100644 index 00000000..53758950 --- /dev/null +++ b/android/feature/teacher/relay/src/main/java/com/sixkids/teacher/relay/history/RelayHistoryContract.kt @@ -0,0 +1,17 @@ +package com.sixkids.teacher.relay.history + +import com.sixkids.model.RunningRelay +import com.sixkids.ui.base.SideEffect +import com.sixkids.ui.base.UiState + +data class RelayHistoryState( + val isLoading: Boolean = false, + val runningRelay: RunningRelay? = null, + val totalRelayCount: Int = 0, +) : UiState + +sealed interface RelayHistoryEffect : SideEffect { + data class NavigateToRelayDetail(val relayId: Long) : RelayHistoryEffect + + data class HandleException(val throwable: Throwable, val retry: () -> Unit) : RelayHistoryEffect +} \ No newline at end of file diff --git a/android/feature/teacher/relay/src/main/java/com/sixkids/teacher/relay/history/RelayHistoryScreen.kt b/android/feature/teacher/relay/src/main/java/com/sixkids/teacher/relay/history/RelayHistoryScreen.kt new file mode 100644 index 00000000..1708b76e --- /dev/null +++ b/android/feature/teacher/relay/src/main/java/com/sixkids/teacher/relay/history/RelayHistoryScreen.kt @@ -0,0 +1,199 @@ +package com.sixkids.teacher.relay.history + +import android.util.Log +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import com.sixkids.designsystem.component.appbar.UlbanDefaultAppBar +import com.sixkids.designsystem.component.appbar.UlbanDetailAppBar +import com.sixkids.designsystem.component.appbar.UlbanDetailWithProgressAppBar +import com.sixkids.designsystem.component.item.UlbanRelayItem +import com.sixkids.designsystem.component.screen.LoadingScreen +import com.sixkids.designsystem.theme.Orange +import com.sixkids.designsystem.theme.OrangeDark +import com.sixkids.designsystem.theme.UlbanTheme +import com.sixkids.designsystem.theme.UlbanTypography +import com.sixkids.model.Relay +import com.sixkids.teacher.relay.R +import com.sixkids.ui.util.formatToMonthDayTime +import com.sixkids.designsystem.R as DesignSystemR + +private const val TAG = "D107" +@Composable +fun RelayHistoryRoute( + viewModel: RelayHistoryViewModel = hiltViewModel(), + padding: PaddingValues, + navigateToDetail: (Long) -> Unit, + handleException: (Throwable, () -> Unit) -> Unit +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(key1 = Unit) { + viewModel.initData() + } + + LaunchedEffect(key1 = viewModel.sideEffect) { + viewModel.sideEffect.collect { sideEffect -> + when (sideEffect) { + is RelayHistoryEffect.NavigateToRelayDetail -> navigateToDetail(sideEffect.relayId) + is RelayHistoryEffect.HandleException -> handleException( + sideEffect.throwable, + sideEffect.retry + ) + } + } + } + + RelayHistoryScreen( + uiState = uiState, + padding = padding, + relayItems = viewModel.relayHistory?.collectAsLazyPagingItems(), + navigateToDetail = { relayId -> + viewModel.navigateToRelayDetail(relayId) + }, + updateTotalCount = { + viewModel.updateTotalCount(it) + } + ) +} + +@Composable +fun RelayHistoryScreen( + uiState: RelayHistoryState = RelayHistoryState(), + padding: PaddingValues = PaddingValues(0.dp), + relayItems: LazyPagingItems? = null, + navigateToDetail: (Long) -> Unit = {}, + updateTotalCount: (Int) -> Unit = {} +) { + val listState = rememberLazyListState() + val isScrolled by remember { + derivedStateOf { + listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 100 + } + } + Box(modifier = Modifier + .fillMaxSize() + .padding(padding)) { + Column( + modifier = Modifier.fillMaxSize() + ) { + val currentRelay = uiState.runningRelay + if (currentRelay == null) { + UlbanDefaultAppBar( + leftIcon = DesignSystemR.drawable.relay, + title = stringResource(R.string.relay), + content = stringResource(R.string.relay_no), + color = Orange, + onclick = {}, + expanded = !isScrolled + ) + } else { + UlbanDetailWithProgressAppBar( + leftIcon = DesignSystemR.drawable.relay, + title = stringResource(R.string.relay), + content = stringResource(R.string.relay_current), + topDescription = "${uiState.runningRelay.startTime.formatToMonthDayTime()} ~", + bottomDescription = stringResource( + R.string.relay_current_runner, + uiState.runningRelay.curMemberNickname + ), + totalCnt = uiState.runningRelay.totalMemberCount, + successCnt = uiState.runningRelay.doneMemberCount, + color = Orange, + progressBarColor = OrangeDark, + expanded = !isScrolled, + onclick = {} + ) + } + + Spacer(modifier = Modifier.padding(12.dp)) + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + ) { + Text( + modifier = Modifier.padding(horizontal = 4.dp), + text = stringResource( + id = R.string.relay_relay_count, + uiState.totalRelayCount + ), + style = UlbanTypography.bodyMedium.copy(fontWeight = FontWeight.Bold) + ) + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + + if (relayItems == null || relayItems.itemCount == 0) { + Spacer(modifier = Modifier.weight(1f)) + Text( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + text = stringResource(R.string.relay_no_history), + style = UlbanTypography.titleLarge, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.weight(1f)) + } else { + LazyColumn( + state = listState, + contentPadding = PaddingValues(vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + items(relayItems.itemCount) { index -> + relayItems[index]?.let { relay -> + if (index == 0){ + updateTotalCount(relay.totalCount) + } + UlbanRelayItem( + startDate = relay.startTime, + endDate = relay.endTime, + userCount = relay.lastTurn, + lastMemberName = relay.lastMemberName, + onClick = {navigateToDetail(relay.id)} + ) + } + } + } + } + } + } + if (uiState.isLoading) { + LoadingScreen() + } + } +} + +@Composable +@Preview(showBackground = true) +fun RelayHistoryScreenPreview() { + UlbanTheme { + RelayHistoryScreen() + } +} \ No newline at end of file diff --git a/android/feature/teacher/relay/src/main/java/com/sixkids/teacher/relay/history/RelayHistoryViewModel.kt b/android/feature/teacher/relay/src/main/java/com/sixkids/teacher/relay/history/RelayHistoryViewModel.kt new file mode 100644 index 00000000..8e968d4f --- /dev/null +++ b/android/feature/teacher/relay/src/main/java/com/sixkids/teacher/relay/history/RelayHistoryViewModel.kt @@ -0,0 +1,86 @@ +package com.sixkids.teacher.relay.history + +import android.util.Log +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn +import androidx.paging.map +import com.sixkids.domain.usecase.organization.GetSelectedOrganizationIdUseCase +import com.sixkids.domain.usecase.relay.GetRelayHistoryUseCase +import com.sixkids.domain.usecase.relay.GetRunningRelayUseCase +import com.sixkids.domain.usecase.user.LoadUserInfoUseCase +import com.sixkids.model.NotFoundException +import com.sixkids.model.Relay +import com.sixkids.model.RunningRelay +import com.sixkids.model.UserInfo +import com.sixkids.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import java.time.LocalDateTime +import javax.inject.Inject + +private const val TAG = "D107" +@HiltViewModel +class RelayHistoryViewModel @Inject constructor( + private val getSelectedOrganizationIdUseCase: GetSelectedOrganizationIdUseCase, + private val getRunningRelayUseCase: GetRunningRelayUseCase, + private val getRelayHistoryUseCase: GetRelayHistoryUseCase +) : BaseViewModel(RelayHistoryState()) +{ + private var orgId = 0L + var relayHistory: Flow>? = null + private var isFirstVisited: Boolean = true + + fun initData() = viewModelScope.launch { + if (isFirstVisited.not()) return@launch + isFirstVisited = false + + intent { copy(isLoading = true) } + + getSelectedOrganizationIdUseCase().onSuccess { + orgId = it.toLong() + }.onFailure { + postSideEffect(RelayHistoryEffect.HandleException(it, ::initData)) + } + + getRunningRelay() + getRelayHistory() + + intent { copy(isLoading = false) } + } + + private fun getRunningRelay() { + viewModelScope.launch { + getRunningRelayUseCase(organizationId = orgId) + .onSuccess { + intent { copy(runningRelay = it) } + }.onFailure { + if (it is NotFoundException){ + intent { copy(runningRelay = null) } + }else{ + postSideEffect( + RelayHistoryEffect.HandleException( + it, + ::getRunningRelay + ) + ) + } + } + } + } + + fun updateTotalCount(totalCount: Int) = intent { copy(totalRelayCount = totalCount) } + + private fun getRelayHistory() { + viewModelScope.launch { + relayHistory = getRelayHistoryUseCase(organizationId = orgId.toInt(), memberId = 0) + .cachedIn(viewModelScope) + } + } + + fun navigateToRelayDetail(relayId: Long) = postSideEffect( + RelayHistoryEffect.NavigateToRelayDetail(relayId) + ) +} \ No newline at end of file diff --git a/android/feature/teacher/relay/src/main/java/com/sixkids/teacher/relay/navigation/RelayNavigation.kt b/android/feature/teacher/relay/src/main/java/com/sixkids/teacher/relay/navigation/RelayNavigation.kt new file mode 100644 index 00000000..51d0d9ee --- /dev/null +++ b/android/feature/teacher/relay/src/main/java/com/sixkids/teacher/relay/navigation/RelayNavigation.kt @@ -0,0 +1,60 @@ +package com.sixkids.teacher.relay.navigation + +import androidx.compose.foundation.layout.PaddingValues +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.NavType +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import com.sixkids.teacher.relay.detail.RelayDetailRoute +import com.sixkids.teacher.relay.history.RelayHistoryRoute +import com.sixkids.teacher.relay.navigation.RelayRoute.RELAY_ID_NAME + +fun NavController.navigateTeacherRelayHistory() { + navigate(RelayRoute.defaultRoute) +} + +fun NavController.navigateTeacherRelayDetail(relayId: Long) { + navigate(RelayRoute.detailRoute(relayId)) +} + +fun NavGraphBuilder.teacherRelayNavGraph( + padding: PaddingValues, + navigateRelayHistory: () -> Unit, + navigateRelayDetail: (Long) -> Unit, + handleException: (Throwable, () -> Unit) -> Unit +) { + composable(route = RelayRoute.defaultRoute) + { + RelayHistoryRoute( + padding = padding, + navigateToDetail = { relayId -> + navigateRelayDetail(relayId) + }, + handleException = handleException + ) + } + + composable(route = RelayRoute.detailRoute, + arguments = listOf( + navArgument(RELAY_ID_NAME) { type = NavType.LongType }, + + )) + { + + RelayDetailRoute( + handleException = handleException + ) + } + +} + +object RelayRoute { + const val RELAY_ID_NAME = "relayId" + + const val defaultRoute = "relay-history" + const val detailRoute = "relay-detail?relayId={$RELAY_ID_NAME}" + + fun detailRoute(relayId: Long) = "relay-detail?relayId=$relayId" +} \ No newline at end of file diff --git a/android/feature/teacher/relay/src/main/res/values/strings.xml b/android/feature/teacher/relay/src/main/res/values/strings.xml new file mode 100644 index 00000000..8e7757b6 --- /dev/null +++ b/android/feature/teacher/relay/src/main/res/values/strings.xml @@ -0,0 +1,12 @@ + + + 진행 중인\n이어 달리기가\n없습니다 + 이어 달리기가\n진행 중입니다! + 현재 주자는 %1$s 학생입니다 + %1$s번 중 %2$s번 진행 중입니다 + 지금까지 %d번 이어 달리기를 진행했어요 + 기록이 없어요 + 이어 달리기 + %s 학생이 폭탄을 터트렸어요 + %d명 학생이 참여했어요 + \ No newline at end of file diff --git a/android/feature/teacher/relay/src/test/java/com/sixkids/teacher/relay/ExampleUnitTest.kt b/android/feature/teacher/relay/src/test/java/com/sixkids/teacher/relay/ExampleUnitTest.kt new file mode 100644 index 00000000..af21e543 --- /dev/null +++ b/android/feature/teacher/relay/src/test/java/com/sixkids/teacher/relay/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.sixkids.teacher.relay + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 87f52a42..b675836a 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -1,6 +1,7 @@ [versions] agp = "8.3.1" kotlin = "1.9.0" +kotlinx-coroutines = "1.7.3" coreKtx = "1.12.0" junit = "4.13.2" junitVersion = "1.1.5" @@ -16,6 +17,7 @@ compose-compiler = "1.5.1" accompanistSystemuicontroller = "0.27.0" hilt-navigation-compose = "1.1.0" androidx-lifecycle = "2.6.2" +permissions = "0.34.0" hilt = "2.48" inject = "1" @@ -29,11 +31,39 @@ retrofit = "2.9.0" moshi-converter = "2.9.0" moshi-kotlin = "1.14.0" okhttp = "4.9.1" +okhttp-sse = "4.9.3" logging-interceptor = "4.8.0" #DataStore datastore = "1.0.0" +#Coil +coil = "2.4.0" + +#Kakao +kakao = "2.20.1" + +#Paging +paging = "3.2.1" +paging-compose = "3.3.0-beta01" + +#accompanist +accompanist = "0.34.0" + +#Lottie +lottie = "6.4.0" + +#Google Services +googleServices = "4.3.14" + +# Firebase +firebaseBom = "33.0.0" +firebaseMessagingKtx = "24.0.0" + +# Krossbow +krossbow = "7.0.0" + +annotationJvm = "1.7.1" [libraries] @@ -61,6 +91,7 @@ androidx-material3 = { group = "androidx.compose.material3", name = "material3" androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hilt-navigation-compose" } androidx-lifecycle-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } +coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } @@ -78,11 +109,44 @@ moshi-converter = { group = "com.squareup.retrofit2", name = "converter-moshi", moshi-kotlin = { group = "com.squareup.moshi", name = "moshi-kotlin", version.ref = "moshi-kotlin" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } okhttp-logginginterceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "logging-interceptor" } +okhttp-sse = { group = "com.squareup.okhttp3", name = "okhttp-sse", version.ref = "okhttp-sse" } datastore = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" } kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version.ref = "kotlinx-datetime" } +kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } + +# Kakao +kakao-sdk = { group = "com.kakao.sdk", name = "v2-all", version.ref = "kakao" } +kakao-user = { group = "com.kakao.sdk", name = "v2-user", version.ref = "kakao" } + +#Paging +paging = { group = "androidx.paging", name = "paging-runtime", version.ref = "paging" } +paging-compose = { group = "androidx.paging", name = "paging-compose", version.ref = "paging-compose" } +paging-common = { group = "androidx.paging", name = "paging-common", version.ref = "paging" } + +#Accompanist +accompanist-pager = { group = "com.google.accompanist", name = "accompanist-pager", version.ref = "accompanist" } +accompanist-pager-indicator = { group = "com.google.accompanist", name = "accompanist-pager-indicators", version.ref = "accompanist" } + +#Permissions +permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "permissions" } + +#Lottie +lottie = { group = "com.airbnb.android", name = "lottie-compose", version.ref = "lottie" } + +#Firebase +firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } +firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics-ktx" } +firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging-ktx" } + +#Krossbow +krossbow-stomp-core = { group = "org.hildan.krossbow", name = "krossbow-stomp-core", version.ref = "krossbow" } +krossbow-websocket-okhttp = { group = "org.hildan.krossbow", name = "krossbow-websocket-okhttp", version.ref = "krossbow" } +krossbow-stomp-moshi = { group = "org.hildan.krossbow", name = "krossbow-stomp-moshi", version.ref = "krossbow" } + +androidx-annotation-jvm = { group = "androidx.annotation", name = "annotation-jvm", version.ref = "annotationJvm" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } @@ -92,6 +156,7 @@ androidLibrary = { id = "com.android.library", version.ref = "agp" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +googleServices = { id = "com.google.gms.google-services", version.ref = "googleServices" } # Plugins defined by this project sixkids-android-application = { id = "sixkids.android.application", version = "unspecified" } @@ -105,6 +170,10 @@ sixkids-android-hilt = { id = "sixkids.android.hilt", version = "unspecified" } [bundles] compose = ["androidx-activity-compose", "androidx-ui", "androidx-ui-graphics", "androidx-ui-tooling-preview", "androidx-ui-test-junit4", "androidx-material3", "androidx-navigation-compose", "androidx-hilt-navigation-compose", "androidx-lifecycle-compose"] compose-debug = ["androidx-ui-tooling", "androidx-ui-test-manifest"] +paging = ["paging", "paging-compose"] # Networking Bundle retrofit = ["retrofit", "moshi-converter", "moshi-kotlin", "okhttp", "okhttp-logginginterceptor"] + +# Firebase Bundle +firebase = [ "firebase-analytics", "firebase-messaging"] diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index f8407c08..d2d8d679 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -19,6 +19,8 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + maven(url = uri("https://jitpack.io")) + maven (url ="https://devrepo.kakao.com/nexus/content/groups/public/") } } @@ -29,6 +31,7 @@ include(":data") include(":core:ui") include(":core:model") include(":core:designsystem") +include(":core:nfc") include(":feature:navigator") include(":feature:home") include(":feature:signin") @@ -41,3 +44,8 @@ include(":feature:student:home") include(":feature:student:board") include(":feature:student:challenge") include(":feature:student:relay") +include(":feature:teacher:challenge") +include(":feature:teacher:main") +include(":core:bluetooth") +include(":feature:student:main") +include(":feature:teacher:relay")