diff --git a/bondoman/app/build.gradle.kts b/bondoman/app/build.gradle.kts index d6e9cc26a2ca62d3fdc183e62ae3fd668b2a2cbb..28930923845a68a45b0c9a60eb0c9016c80a5ead 100644 --- a/bondoman/app/build.gradle.kts +++ b/bondoman/app/build.gradle.kts @@ -60,4 +60,9 @@ dependencies { implementation("com.squareup.retrofit2:retrofit:2.9.0") implementation("com.squareup.retrofit2:converter-gson:2.9.0") implementation("androidx.security:security-crypto:1.0.0") + implementation ("androidx.camera:camera-core:1.3.2") + implementation ("androidx.camera:camera-camera2:1.3.2") + implementation ("androidx.camera:camera-lifecycle:1.3.2") + implementation ("androidx.camera:camera-view:1.3.2") + implementation ("androidx.camera:camera-extensions:1.3.2") } \ No newline at end of file diff --git a/bondoman/app/src/main/AndroidManifest.xml b/bondoman/app/src/main/AndroidManifest.xml index c433079e8c33e070cdaf0b6ff2ba95420006b277..163f17aa65ac3549f8d2706c30b786b3d461ea27 100644 --- a/bondoman/app/src/main/AndroidManifest.xml +++ b/bondoman/app/src/main/AndroidManifest.xml @@ -1,7 +1,14 @@ <?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools"> - <uses-permission android:name="android.permission.INTERNET"/> + + <uses-feature android:name="android.hardware.camera.any"/> + + <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/> + <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/> + <uses-permission android:name="android.permission.CAMERA"/> + + <uses-permission android:name="android.permission.INTERNET" /> <application android:allowBackup="true" @@ -9,6 +16,7 @@ android:fullBackupContent="@xml/backup_rules" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" + android:requestLegacyExternalStorage="true" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.Bondoman" diff --git a/bondoman/app/src/main/java/com/example/bondoman/ScanPage.kt b/bondoman/app/src/main/java/com/example/bondoman/ScanPage.kt index 96e928bb30506211cd78bc161ec05865a609f447..3ac30ccda75c3d305fad2988cfe45cd513a82c35 100644 --- a/bondoman/app/src/main/java/com/example/bondoman/ScanPage.kt +++ b/bondoman/app/src/main/java/com/example/bondoman/ScanPage.kt @@ -1,59 +1,328 @@ package com.example.bondoman +import android.app.Activity +import android.content.ContentValues +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build import android.os.Bundle +import android.provider.MediaStore +import android.util.Log import androidx.fragment.app.Fragment import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.Toast +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageCapture +import androidx.camera.core.ImageCaptureException +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.lifecycle.lifecycleScope +import com.example.bondoman.databinding.FragmentScanPageBinding +import com.example.bondoman.retrofit.Retrofit +import com.example.bondoman.retrofit.data.Item +import com.example.bondoman.retrofit.endpoint.EndpointScan +import com.example.bondoman.room.Transaction +import com.example.bondoman.room.TransactionDB +import com.example.bondoman.utils.AuthManager +import com.example.bondoman.utils.AuthManager.getToken +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import okhttp3.MediaType +import okhttp3.MultipartBody +import okhttp3.RequestBody +import org.json.JSONObject +import java.io.File +import java.nio.charset.StandardCharsets +import java.text.SimpleDateFormat +import java.util.Base64 +import java.util.Locale -// TODO: Rename parameter arguments, choose names that match -// the fragment initialization parameters, e.g. ARG_ITEM_NUMBER -private const val ARG_PARAM1 = "param1" -private const val ARG_PARAM2 = "param2" - -/** - * A simple [Fragment] subclass. - * Use the [ScanPage.newInstance] factory method to - * create an instance of this fragment. - */ class ScanPage : Fragment() { - // TODO: Rename and change types of parameters - private var param1: String? = null - private var param2: String? = null - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - arguments?.let { - param1 = it.getString(ARG_PARAM1) - param2 = it.getString(ARG_PARAM2) - } - } + private var _binding: FragmentScanPageBinding? = null + private val binding get() = _binding!! + private var imageCapture: ImageCapture? = null + private val PICK_IMAGE_REQUEST = 2 + + val db by lazy { TransactionDB(requireContext()) } + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { - // Inflate the layout for this fragment - return inflater.inflate(R.layout.fragment_scan_page, container, false) + _binding = FragmentScanPageBinding.inflate(inflater, container, false) + return binding.root } - companion object { - /** - * Use this factory method to create a new instance of - * this fragment using the provided parameters. - * - * @param param1 Parameter 1. - * @param param2 Parameter 2. - * @return A new instance of fragment ScanPage. - */ - // TODO: Rename and change types and number of parameters - @JvmStatic - fun newInstance(param1: String, param2: String) = - ScanPage().apply { - arguments = Bundle().apply { - putString(ARG_PARAM1, param1) - putString(ARG_PARAM2, param2) + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + if (allPermissionsGranted()) { + startCamera() + } else { + ActivityCompat.requestPermissions( + requireActivity(), REQUIRED_PERMISSIONS, 10 + ) + } + + binding.shutterButton.setOnClickListener { + takePhoto() + } + + binding.selectButton.setOnClickListener { + selectPhoto() + } + } + + private fun selectPhoto() { + val galleryIntent = Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI) + startActivityForResult(galleryIntent, PICK_IMAGE_REQUEST) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + Toast.makeText(requireContext(), "Loading...", Toast.LENGTH_SHORT).show() + + if (requestCode == PICK_IMAGE_REQUEST && resultCode == Activity.RESULT_OK && data != null) { + val selectedImageUri: Uri? = data.data + Log.d("Scan - select photo", "Selected Image URI: $selectedImageUri") + val imagePath = selectedImageUri?.let { getPathFromUri(it) } + Log.d("Scan - select photo", "Image Path: $imagePath") + if (imagePath != null) { + sendImageToAPI(imagePath) + } + } + } + + private fun getPathFromUri(uri: Uri): String? { + val projection = arrayOf(MediaStore.Images.Media.DATA) + val cursor = requireContext().contentResolver.query(uri, projection, null, null, null) + cursor?.use { + val columnIndex = it.getColumnIndexOrThrow(MediaStore.Images.Media.DATA) + it.moveToFirst() + return it.getString(columnIndex) + } + return null + } + + private fun takePhoto(): Unit { + val imageCapture = imageCapture ?: return + + val title = SimpleDateFormat(FILENAME_FORMAT, Locale("in", "ID")).format(System.currentTimeMillis()) + val contentValues = ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, title) + put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") + if(Build.VERSION.SDK_INT > Build.VERSION_CODES.P) { + put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/Bondoman-Scan") + } + } + + val outputOptions = ImageCapture.OutputFileOptions.Builder( + requireContext().contentResolver, + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + contentValues + ).build() + + imageCapture.takePicture( + outputOptions, ContextCompat.getMainExecutor(requireContext()), + object : ImageCapture.OnImageSavedCallback { + override fun onError(exc: ImageCaptureException) { + Log.e("Camera", "Photo capture failed: ${exc.message}", exc) + Toast.makeText(requireContext(), "Photo capture failed: ${exc.message}", Toast.LENGTH_SHORT).show() + } + + override fun onImageSaved(output: ImageCapture.OutputFileResults) { + Toast.makeText(requireContext(), "Loading...", Toast.LENGTH_SHORT).show() + val savedUri = output.savedUri ?: MediaStore.Images.Media.EXTERNAL_CONTENT_URI + if (savedUri != MediaStore.Images.Media.EXTERNAL_CONTENT_URI) { + val contentResolver = requireContext().contentResolver + val cursor = contentResolver.query(savedUri, null, null, null, null) + cursor?.use { + val dataIndex = it.getColumnIndexOrThrow(MediaStore.Images.Media.DATA) + if (it.moveToFirst()) { + val path = it.getString(dataIndex) + Log.d("Camera", "File path: $path") + sendImageToAPI(path) + } + } + cursor?.close() + } else { + Log.d("Camera", "Saved URI is not a content URI") + } } + } + ) + } + + private fun startCamera() { + val cameraProviderFuture = ProcessCameraProvider.getInstance(requireContext()) + + cameraProviderFuture.addListener(Runnable { + val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get() + + val preview = Preview.Builder().build().also { + it.setSurfaceProvider(binding.previewView.surfaceProvider) + } + + val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA + + imageCapture = ImageCapture.Builder().build() + + try { + cameraProvider.unbindAll() + cameraProvider.bindToLifecycle( + this, cameraSelector, preview, imageCapture) + + } catch(exc: Exception) { + Log.e("Camera", "Use case binding failed", exc) + } + + }, ContextCompat.getMainExecutor(requireContext())) + } + + private fun allPermissionsGranted() = REQUIRED_PERMISSIONS.all { + ContextCompat.checkSelfPermission( + requireContext(), it + ) == PackageManager.PERMISSION_GRANTED + + } + + override fun onRequestPermissionsResult( + requestCode: Int, permissions: Array<String>, grantResults: + IntArray) { + if (requestCode == 10) { + if (allPermissionsGranted()) { + startCamera() + } else { + Log.d("Permission", "Permissions not granted by the user.") + Toast.makeText(requireContext(), "Permissions not granted by the user.", Toast.LENGTH_SHORT).show() + } + } + } + + private fun sendImageToAPI(pathname: String) { + val token = getToken(requireContext()) + Log.d("Scan", "Token: $token") + + val retro = Retrofit.getInstance().create(EndpointScan::class.java) + Log.d("Scan", "pathname: ${pathname}") + + + val file = File(pathname) + val requestBody = RequestBody.create(MediaType.parse("image/jpeg"), file) + val filePart = MultipartBody.Part.createFormData("file", file.name, requestBody) + Log.d("Scan", "filePart: ${filePart}") + + lifecycleScope.launch { + try { + val response = retro.scan("Bearer $token", filePart) + Log.d("Scan", "Response: ${response}") + if (response.isSuccessful) { + val data = response.body() + if (data != null) { +// Log.d("Scan", "Success: ${data.items}") +// Log.d("Scan", "Success: ${data}") + + val items = data.items.items +// val singleItem = listOf(items) + val cleanedItems = mutableListOf<Item>() + for (item in items) { + val name = item.name +// Log.d("Scan", "Name: $name") + val price = item.price +// Log.d("Scan", "Price: $price") + val qty = item.qty +// Log.d("Scan", "Qty: $qty") + + val newItem = Item(name, qty, price) + cleanedItems.add(newItem) + } + addToDatabase(cleanedItems) + + } + } + else { + val errorBody = response.errorBody()?.string() + if (!errorBody.isNullOrEmpty()) { + val errorMessage = errorBody + Toast.makeText(requireContext(), errorMessage, Toast.LENGTH_SHORT).show() + } + } + + } + catch (e: java.lang.Exception){ + Log.e("Scan", "Error: ${e.message}") + Toast.makeText(requireContext(), "An error occured: ${e.message}", Toast.LENGTH_SHORT).show() + } + } + + } + + private fun addToDatabase(data: MutableList<Item>) { + CoroutineScope(lifecycleScope.coroutineContext).launch { + val token = AuthManager.getToken(requireContext()).toString().split('.') +// Log.d("Scan - to db", "token ${token}") + + if (token.size != 3) { + Log.e("Scan - to db", "Invalid token format") + return@launch + } + + val payload = Base64.getUrlDecoder().decode(token[1]) +// Log.d("Scan - to db", "payload ${String(payload, StandardCharsets.UTF_8)}") + + val payloadJson = JSONObject(String(payload, StandardCharsets.UTF_8)) +// Log.d("Scan - to db", "payloadJson ${payloadJson}") + + var count = 0 + for (item in data) { + db.transactionDao().addTransaction( + Transaction( + 0, + payloadJson.optString("nim"), + item.name, + item.price.toString(), + item.qty.toString(), + "", + SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(System.currentTimeMillis()) + ) + ) + +// Log.d("Scan - to db", "Added to db: ${item.name}, ${item.price}, ${item.qty}") + count++ + } + + Toast.makeText(requireContext(), "Berhasil menambahkan ${count} transaksi", Toast.LENGTH_SHORT).show() + + // return to transaction page + val fragment = TransactionPage() + val transaction = requireFragmentManager().beginTransaction() + transaction.replace(R.id.frame_layout, fragment).commit() + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + companion object { + private val REQUIRED_PERMISSIONS = + mutableListOf ( + android.Manifest.permission.CAMERA + ).apply { + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) { + add(android.Manifest.permission.WRITE_EXTERNAL_STORAGE) + } + }.toTypedArray() + + private const val FILENAME_FORMAT = "dd-MM-yyyy-HH-mm-ss-SSS" } } \ No newline at end of file diff --git a/bondoman/app/src/main/java/com/example/bondoman/retrofit/data/ItemResponse.kt b/bondoman/app/src/main/java/com/example/bondoman/retrofit/data/ItemResponse.kt new file mode 100644 index 0000000000000000000000000000000000000000..d464fcea2606d17b4465ef2af89f45bf0664986b --- /dev/null +++ b/bondoman/app/src/main/java/com/example/bondoman/retrofit/data/ItemResponse.kt @@ -0,0 +1,6 @@ +package com.example.bondoman.retrofit.data + +data class ItemResponse( + val items: Items +) + diff --git a/bondoman/app/src/main/java/com/example/bondoman/retrofit/data/Items.kt b/bondoman/app/src/main/java/com/example/bondoman/retrofit/data/Items.kt new file mode 100644 index 0000000000000000000000000000000000000000..7fd6e1ca83419d573799d14dfb2db4dd51e0006f --- /dev/null +++ b/bondoman/app/src/main/java/com/example/bondoman/retrofit/data/Items.kt @@ -0,0 +1,11 @@ +package com.example.bondoman.retrofit.data + +data class Items( + val items: List<Item> +) + +data class Item( + val name: String, + val qty: Int, + val price: Double +) \ No newline at end of file diff --git a/bondoman/app/src/main/java/com/example/bondoman/retrofit/endpoint/EndpointScan.kt b/bondoman/app/src/main/java/com/example/bondoman/retrofit/endpoint/EndpointScan.kt new file mode 100644 index 0000000000000000000000000000000000000000..75fffa545b245a3c0cc7e776851acec9d501d275 --- /dev/null +++ b/bondoman/app/src/main/java/com/example/bondoman/retrofit/endpoint/EndpointScan.kt @@ -0,0 +1,18 @@ +package com.example.bondoman.retrofit.endpoint + +import com.example.bondoman.retrofit.data.ItemResponse +import okhttp3.MultipartBody +import retrofit2.Response +import retrofit2.http.Header +import retrofit2.http.Multipart +import retrofit2.http.POST +import retrofit2.http.Part + +interface EndpointScan { + @POST("api/bill/upload") + @Multipart + suspend fun scan( + @Header("Authorization") token: String, + @Part file : MultipartBody.Part + ): Response<ItemResponse> +} \ No newline at end of file diff --git a/bondoman/app/src/main/java/com/example/bondoman/retrofit/interceptor/AuthInterceptor.kt b/bondoman/app/src/main/java/com/example/bondoman/retrofit/interceptor/AuthInterceptor.kt new file mode 100644 index 0000000000000000000000000000000000000000..25a042250076d81b016b5d8999d6f03c9603aea6 --- /dev/null +++ b/bondoman/app/src/main/java/com/example/bondoman/retrofit/interceptor/AuthInterceptor.kt @@ -0,0 +1,13 @@ +package com.example.bondoman.retrofit.interceptor + +import okhttp3.Interceptor +import okhttp3.Response + +class AuthInterceptor(private val token: String?) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request().newBuilder() + .addHeader("Authorization", "Bearer $token") + .build() + return chain.proceed(request) + } +} diff --git a/bondoman/app/src/main/res/drawable/select_button.xml b/bondoman/app/src/main/res/drawable/select_button.xml new file mode 100644 index 0000000000000000000000000000000000000000..a3a77cd64167cf2cd99c8f0f0d96e3ec0aa8f100 --- /dev/null +++ b/bondoman/app/src/main/res/drawable/select_button.xml @@ -0,0 +1,5 @@ +<shape xmlns:android="http://schemas.android.com/apk/res/android" + android:shape="rectangle"> + <solid android:color="@color/white" /> + <corners android:radius="20dp" /> +</shape> diff --git a/bondoman/app/src/main/res/drawable/shutter_button.xml b/bondoman/app/src/main/res/drawable/shutter_button.xml new file mode 100644 index 0000000000000000000000000000000000000000..f3e76d52df6c545190ef044bd4966bd8f6bd06ef --- /dev/null +++ b/bondoman/app/src/main/res/drawable/shutter_button.xml @@ -0,0 +1,4 @@ +<shape xmlns:android="http://schemas.android.com/apk/res/android" + android:shape="oval"> + <solid android:color="@color/red" /> +</shape> diff --git a/bondoman/app/src/main/res/layout/fragment_scan_page.xml b/bondoman/app/src/main/res/layout/fragment_scan_page.xml index 549c8d4f06ec9d9115bf31fff01003eb369a0d96..df3968f8f21872d7a4d2c231666a0a06f686286e 100644 --- a/bondoman/app/src/main/res/layout/fragment_scan_page.xml +++ b/bondoman/app/src/main/res/layout/fragment_scan_page.xml @@ -1,14 +1,61 @@ <?xml version="1.0" encoding="utf-8"?> -<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" +<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" + android:id="@+id/frameLayout2" android:layout_width="match_parent" android:layout_height="match_parent" + android:background="@color/primary" + android:pointerIcon="none" tools:context=".ScanPage"> - <!-- TODO: Update blank fragment layout --> <TextView - android:layout_width="match_parent" - android:layout_height="match_parent" - android:text="@string/title_scan_page" /> + android:id="@+id/txt1_scan" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="80dp" + android:text="SCAN" + android:textSize="32sp" + android:textStyle="bold" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> -</FrameLayout> \ No newline at end of file + <LinearLayout + android:id="@+id/layout_scan" + android:layout_width="270dp" + android:layout_height="350dp" + app:layout_constraintTop_toBottomOf="@id/txt1_scan" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toEndOf="parent" + android:layout_marginTop="40dp" + > + <androidx.camera.view.PreviewView + android:id="@+id/previewView" + android:layout_width="match_parent" + android:layout_height="match_parent" /> + </LinearLayout> + + <Button + android:id="@+id/shutter_button" + android:layout_width="64dp" + android:layout_height="64dp" + android:layout_marginTop="50dp" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/layout_scan" + android:background="@drawable/shutter_button" + /> + + <Button + android:id="@+id/select_button" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="40dp" + android:text=" select photos " + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/shutter_button" + android:background="@drawable/select_button"/> + +</androidx.constraintlayout.widget.ConstraintLayout> \ No newline at end of file diff --git a/bondoman/app/src/main/res/values/colors.xml b/bondoman/app/src/main/res/values/colors.xml index 2d68bba60374c48235b5efe4fa77bdd810efa599..5bb8d0e0f507e9709656c67cb3b762e7c3d9234d 100644 --- a/bondoman/app/src/main/res/values/colors.xml +++ b/bondoman/app/src/main/res/values/colors.xml @@ -3,7 +3,7 @@ <color name="black">#FF000000</color> <color name="white">#EEEEEE</color> <color name="grey">#D9D9D9</color> - <color name="red">#CE0000</color> + <color name="red">#B70000</color> <color name="primary">#E1DAAD</color> <color name="secondary">#4B230B</color>