This commit is contained in:
Nucha 2025-06-12 14:54:44 +07:00
parent 79367aef7f
commit 46a9b5b82e
12 changed files with 246 additions and 61 deletions

View File

@ -4,6 +4,14 @@
<selectionStates> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-06-12T01:51:13.726519Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="LocalEmulator" identifier="path=/Users/nucha/.android/avd/Medium_Phone_API_33.avd" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState> </SelectionState>
</selectionStates> </selectionStates>
</component> </component>

View File

@ -41,6 +41,9 @@ android {
} }
dependencies { dependencies {
val composeBom = platform("androidx.compose:compose-bom:2025.05.00")
implementation(composeBom)
androidTestImplementation(composeBom)
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
@ -62,6 +65,7 @@ dependencies {
implementation(libs.androidx.navigation.fragment.ktx) implementation(libs.androidx.navigation.fragment.ktx)
implementation(libs.androidx.navigation.ui.ktx) implementation(libs.androidx.navigation.ui.ktx)
implementation(libs.ads.mobile.sdk) implementation(libs.ads.mobile.sdk)
implementation(libs.accompanist.permissions)
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)

View File

@ -1,7 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<application <application
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
@ -13,6 +14,8 @@
android:theme="@style/Theme.AttendanceManager" android:theme="@style/Theme.AttendanceManager"
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"
tools:targetApi="31"> tools:targetApi="31">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"

View File

@ -2,22 +2,25 @@ import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.gson.GsonConverterFactory
object ApiClient { object ApiClient {
private const val BASE_URL = "http://10.0.2.2:8000" // ใช้ 10.0.2.2 แทน localhost สำหรับ emulator // กำหนด BASE_URL ของ API ใช้ 10.0.2.2 แทน localhost สำหรับ Android emulator
private const val BASE_URL = "http://10.0.2.2:8000"
// สร้าง Retrofit instance เดียว ใช้ lazy initialization คือสร้างเมื่อเรียกใช้ครั้งแรก
private val retrofit: Retrofit by lazy {
Retrofit.Builder()
.baseUrl(BASE_URL) // กำหนด URL หลักของ API
.addConverterFactory(GsonConverterFactory.create()) // ใช้ Gson แปลง JSON เป็น object และกลับกัน
.build()
}
// สร้าง AuthApiService จาก Retrofit instance เดียวกัน
val authApi: AuthApiService by lazy { val authApi: AuthApiService by lazy {
Retrofit.Builder() retrofit.create(AuthApiService::class.java)
.baseUrl(BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(AuthApiService::class.java)
} }
// สร้าง AttendanceApiService จาก Retrofit instance เดียวกัน
val attendanceApi: AttendanceApiService by lazy { val attendanceApi: AttendanceApiService by lazy {
Retrofit.Builder() retrofit.create(AttendanceApiService::class.java)
.baseUrl(BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(AttendanceApiService::class.java)
} }
}
}

View File

@ -0,0 +1,33 @@
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
class AttendanceViewModel : ViewModel() {
// ฟังก์ชัน checkIn รับ token, พิกัด lat,long และ callback สำหรับผลลัพธ์
fun checkIn(token: String, lat: Double, long: Double, callback: (Boolean, String) -> Unit) {
// สร้าง request body จากพิกัดที่รับเข้ามา
val body = CheckInRequest(lat = lat, long = long)
// ใช้ viewModelScope เพื่อ launch coroutine ทำงานแบบ background
viewModelScope.launch {
try {
// เรียก API check-in ผ่าน Retrofit โดยส่ง token ใน header แบบ Bearer และ body ใน POST
val response = ApiClient.attendanceApi.checkIn("Bearer $token", body)
if (response.isSuccessful) {
// ถ้า response สำเร็จ ดึงข้อความเวลาที่เช็คอินสำเร็จจาก response body
val msg = response.body()?.checked_in_at ?: "Check-in สำเร็จ"
// ส่ง callback กลับว่า success พร้อมข้อความ
callback(true, msg)
} else {
// กรณี response ล้มเหลว ส่ง callback พร้อมสถานะล้มเหลวและรหัส HTTP
callback(false, "Check-in ล้มเหลว: ${response.code()}")
}
} catch (e: Exception) {
// กรณีเกิดข้อผิดพลาดระหว่างเรียก API เช่น network error
callback(false, "เกิดข้อผิดพลาด: ${e.localizedMessage}")
}
}
}
}

View File

@ -1,8 +1,14 @@
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.Response import retrofit2.Response
interface AuthApiService { interface AuthApiService {
// ฟังก์ชันสำหรับเรียก API login ด้วย HTTP POST ไปที่ "/auth/login"
// ใช้ coroutine (suspend) เพื่อให้สามารถเรียกแบบ asynchronous ได้
// รับพารามิเตอร์เป็น LoginRequest (username/password)
// และคืนค่ากลับเป็น Response<LoginResponse> ที่มี token หรือข้อมูลอื่นใน response
@POST("/auth/login") @POST("/auth/login")
suspend fun login(@Body request: LoginRequest): Response<LoginResponse> suspend fun login(@Body request: LoginRequest): Response<LoginResponse>
} }
//suspend คือ คีย์เวิร์ดในภาษา Kotlin ที่ใช้สำหรับระบุว่า ฟังก์ชันสามารถหยุดทำงานชั่วคราว (suspend) และ resume ต่อได้ภายหลัง ซึ่งเป็นพื้นฐานของ Coroutine เพื่อให้สามารถทำงานแบบ asynchronous (ไม่บล็อก UI) ได้ง่ายและปลอดภัยกว่าแบบเดิม (เช่น AsyncTask หรือ Thread)

View File

@ -1,5 +1,6 @@
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
data class LoginRequest( data class LoginRequest(
val email: String, val email: String,
@ -17,35 +18,35 @@ class AuthViewModel : ViewModel() {
callback(false, "Email หรือ Password ว่าง") callback(false, "Email หรือ Password ว่าง")
return@launch return@launch
} }
//
// // จำลองการเรียก API ที่ใช้เวลาประมาณ 2 วินาที // จำลองการเรียก API ที่ใช้เวลาประมาณ 2 วินาที
// delay(2000) delay(2000)
//
//
//
// // ตัวอย่าง login จำลอง: email = admin@example.com, password = 1234 // ตัวอย่าง login จำลอง: email = admin@example.com, password = 1234
// if (email == "admin@example.com" && password == "1234") { if (email == "test@test.com" && password == "1234") {
// callback(true, null) callback(true, null)
// } else { } else {
// callback(false, "อีเมลหรือรหัสผ่านไม่ถูกต้อง") callback(false, "อีเมลหรือรหัสผ่านไม่ถูกต้อง")
// }
// }
try {
val response = ApiClient.authApi.login(LoginRequest(email, password))
if (response.isSuccessful) {
val token = response.body()?.token
if (!token.isNullOrBlank()) {
// ✅ login สำเร็จ
callback(true, null)
} else {
callback(false, "ไม่พบ token")
}
} else {
callback(false, "อีเมลหรือรหัสผ่านไม่ถูกต้อง")
}
} catch (e: Exception) {
callback(false, "เกิดข้อผิดพลาดในการเชื่อมต่อ: ${e.localizedMessage}")
} }
} }
// try {
// val response = ApiClient.authApi.login(LoginRequest(email, password))
// if (response.isSuccessful) {
// val token = response.body()?.token
// if (!token.isNullOrBlank()) {
// // ✅ login สำเร็จ
// callback(true, null)
// } else {
// callback(false, "ไม่พบ token")
// }
// } else {
// callback(false, "อีเมลหรือรหัสผ่านไม่ถูกต้อง")
// }
// } catch (e: Exception) {
// callback(false, "เกิดข้อผิดพลาดในการเชื่อมต่อ: ${e.localizedMessage}")
// }
// }
} }
} }

View File

@ -1,14 +1,126 @@
import androidx.compose.foundation.layout.Column // นำเข้า permission, location และ library ที่เกี่ยวข้อง
import androidx.compose.foundation.layout.fillMaxSize import android.Manifest
import androidx.compose.foundation.layout.padding import android.content.pm.PackageManager
import androidx.compose.material3.Text import android.widget.Toast
import androidx.compose.runtime.Composable import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.app.ActivityCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.android.gms.location.LocationServices
import com.google.accompanist.permissions.*
/**
* อมลทไดจากการตอบกลบของ API หลงจาก Check In สำเร
*/
data class CheckInResponse(
val checked_in_at: String
)
/**
* อมลทองสงให API เพอทำการ Check In
*/
data class CheckInRequest(
val lat: Double,
val long: Double
)
/**
* Composable ใชแสดงหนาจอ Check In/Out และจดการการรองขอ permission
*/
@OptIn(ExperimentalPermissionsApi::class) // ใช้ API ที่ยังเป็น experimental จาก Accompanist
@Composable @Composable
fun CheckInOutScreen() { fun CheckInOutScreen() {
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { // ใช้ Accompanist ในการจัดการ permission สำหรับ location
Text("Check In / Out Screen") val locationPermissionState = rememberPermissionState(Manifest.permission.ACCESS_FINE_LOCATION)
// Context ของแอป สำหรับแสดง Toast หรือใช้ API อื่น ๆ//
val ctx = LocalContext.current
// ใช้ remember เก็บข้อความที่ใช้แสดงผลลัพธ์จาก server
val message = remember { mutableStateOf<String?>(null) }
// ดึง AttendanceViewModel เพื่อเรียกใช้ฟังก์ชัน checkIn
val viewModel: AttendanceViewModel = viewModel()
// token จำลองไว้เรียก API
val token = "fake-jwt-token"
// สร้าง fusedLocationClient สำหรับเข้าถึง location ล่าสุดของผู้ใช้
val fusedLocationClient = remember {
LocationServices.getFusedLocationProviderClient(ctx)
} }
}
/**
* งกนเรยก permission และเรยกใช fusedLocationClient เพอดงตำแหน
* แลวสงไปย API สำหร Check In
*/
fun getLocationAndCheckIn() {
// ถ้ายังไม่ได้รับ permission ให้เรียกขอ permission แล้วออกจากฟังก์ชันก่อน
if (locationPermissionState.status != PermissionStatus.Granted) {
locationPermissionState.launchPermissionRequest()
return
}
try {
// เมื่อได้รับ permission แล้ว ใช้ fusedLocationClient ดึงตำแหน่งล่าสุด
fusedLocationClient.lastLocation.addOnSuccessListener { location ->
if (location != null) {
val lat = location.latitude
val long = location.longitude
// เรียก ViewModel เพื่อติดต่อ API และส่งพิกัดไป
viewModel.checkIn(token, lat, long) { success, msg ->
message.value = msg
Toast.makeText(ctx, msg, Toast.LENGTH_SHORT).show()
}
} else {
Toast.makeText(ctx, "ไม่สามารถดึงพิกัดได้", Toast.LENGTH_SHORT).show()
}
}
} catch (e: SecurityException) {
// ถ้าเกิด SecurityException แสดงว่า permission ไม่ได้เปิดไว้จริง
Toast.makeText(ctx, "ไม่มีสิทธิ์เข้าถึงตำแหน่ง", Toast.LENGTH_SHORT).show()
}
}
// UI Layout แบบ Column
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
// แสดงหัวข้อของหน้าจอ
Text("Check In / Out Screen", style = MaterialTheme.typography.headlineMedium)
Spacer(modifier = Modifier.height(16.dp))
// ตรวจสอบสถานะ permission และแสดง UI ที่เหมาะสม
when (val status = locationPermissionState.status) {
is PermissionStatus.Granted -> {
// ถ้าอนุญาตแล้ว แสดงปุ่ม Check In
Button(onClick = { getLocationAndCheckIn() }) {
Text("Check In")
}
}
is PermissionStatus.Denied -> {
if (status.shouldShowRationale) {
// ผู้ใช้ปฏิเสธแบบชั่วคราว ให้แสดงเหตุผลและปุ่มขอใหม่
Text("แอปต้องขออนุญาตเข้าถึงตำแหน่ง เพื่อทำการ Check In")
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = { locationPermissionState.launchPermissionRequest() }) {
Text("ขออนุญาต")
}
} else {
// ผู้ใช้ปฏิเสธถาวร (Don't ask again) แจ้งให้ไปเปิดใน Settings
Text("กรุณาเปิดสิทธิ์ตำแหน่งในตั้งค่าเครื่อง")
}
}
}
Spacer(modifier = Modifier.height(16.dp))
// แสดงข้อความตอบกลับจาก server ถ้ามี
message.value?.let {
Text("Server Response: $it")
}
}
}

View File

@ -4,12 +4,20 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
/**
* มแบบกำหนดเอง (CustomButton) สำหรบใชำในหลาย วนของ UI
*
* @param text อความทจะแสดงบนป
* @param onClick งกนทจะถกเรยกเมอผใชกดป
*/
@Composable @Composable
fun CustomButton(text: String, onClick: () -> Unit) { fun CustomButton(text: String, onClick: () -> Unit) {
// ปุ่มจาก Material3 ที่ใช้คลิกได้
Button( Button(
onClick = onClick, onClick = onClick, // ฟังก์ชันที่จะถูกเรียกเมื่อกดปุ่ม
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth() // ปุ่มขยายเต็มความกว้างของหน้าจอ
) { ) {
// ข้อความที่จะแสดงอยู่ในปุ่ม
Text(text) Text(text)
} }
} }

View File

@ -17,6 +17,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.rememberNavController
import com.yourcompany.attendancemanager.R import com.yourcompany.attendancemanager.R
@Composable @Composable
@ -29,7 +30,10 @@ fun LoginScreen(onLoginSuccess: () -> Unit) {
val loginFailedText = stringResource(R.string.login_failed) val loginFailedText = stringResource(R.string.login_failed)
val isLoading = remember { mutableStateOf(false) } val isLoading = remember { mutableStateOf(false) }
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Column(modifier = Modifier
.fillMaxSize()
.padding(16.dp)) {
Text(text = loginText, style = MaterialTheme.typography.headlineMedium) Text(text = loginText, style = MaterialTheme.typography.headlineMedium)
InputField(label = stringResource(R.string.email), text = email.value) { email.value = it } InputField(label = stringResource(R.string.email), text = email.value) { email.value = it }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
@ -50,5 +54,6 @@ fun LoginScreen(onLoginSuccess: () -> Unit) {
} }
} }
} }
} }
} }

View File

@ -25,9 +25,9 @@ class MainActivity : ComponentActivity() {
LoginScreen(onLoginSuccess = { navController.navigate("check") }) LoginScreen(onLoginSuccess = { navController.navigate("check") })
} }
composable("check") { CheckInOutScreen() } composable("check") { CheckInOutScreen() }
composable("leave") { LeaveRequestScreen() } // composable("leave") { LeaveRequestScreen() }
composable("leaveSummary"){ LeaveSummaryScreen() } // composable("leaveSummary"){ LeaveSummaryScreen() }
composable("attendanceSummary"){ AttendanceSummaryScreen() } // composable("attendanceSummary"){ AttendanceSummaryScreen() }
} }
} }
} }

View File

@ -20,6 +20,7 @@ constraintlayout = "2.2.1"
navigationFragmentKtx = "2.9.0" navigationFragmentKtx = "2.9.0"
navigationUiKtx = "2.9.0" navigationUiKtx = "2.9.0"
adsMobileSdk = "0.16.0-alpha01" adsMobileSdk = "0.16.0-alpha01"
accompanist = "0.31.5-beta"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@ -48,6 +49,7 @@ androidx-constraintlayout = { group = "androidx.constraintlayout", name = "const
androidx-navigation-fragment-ktx = { group = "androidx.navigation", name = "navigation-fragment-ktx", version.ref = "navigationFragmentKtx" } androidx-navigation-fragment-ktx = { group = "androidx.navigation", name = "navigation-fragment-ktx", version.ref = "navigationFragmentKtx" }
androidx-navigation-ui-ktx = { group = "androidx.navigation", name = "navigation-ui-ktx", version.ref = "navigationUiKtx" } androidx-navigation-ui-ktx = { group = "androidx.navigation", name = "navigation-ui-ktx", version.ref = "navigationUiKtx" }
ads-mobile-sdk = { group = "com.google.android.libraries.ads.mobile.sdk", name = "ads-mobile-sdk", version.ref = "adsMobileSdk" } ads-mobile-sdk = { group = "com.google.android.libraries.ads.mobile.sdk", name = "ads-mobile-sdk", version.ref = "adsMobileSdk" }
accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }