diff --git a/app/src/main/java/pbd/tubes/exe_android/MainActivity.kt b/app/src/main/java/pbd/tubes/exe_android/MainActivity.kt index 6da30dcaeb9d4bb1bbf900fc6fdf3adaf4f9ebc8..e7e062597fec8c38e01e1c7201cc7cffc968b947 100644 --- a/app/src/main/java/pbd/tubes/exe_android/MainActivity.kt +++ b/app/src/main/java/pbd/tubes/exe_android/MainActivity.kt @@ -20,7 +20,6 @@ class MainActivity : AppCompatActivity() { private lateinit var binding: ActivityMainBinding // private var isFabVisible : Boolean = true - private var isLoggedIn: Boolean = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -39,6 +38,17 @@ class MainActivity : AppCompatActivity() { setContentView(binding.root) val navController = findNavController(R.id.nav_host_fragment_activity_main) + // Checking current fragment to show/hide fab + // TODO(checking currentDestination id, this only check "navigation") + val currentDestinationId = navController.currentDestination!!.id + if (currentDestinationId != R.id.navigation_transactions){ + Log.d("MyApp", "current fragment is $currentDestinationId, hiding fab") + binding.addFab.hide() + } else { + Log.d("MyApp", "current fragment is $currentDestinationId, showing fab") + binding.addFab.show() + } + when (resources.configuration.orientation){ Configuration.ORIENTATION_LANDSCAPE -> { binding.navViewRail?.let { diff --git a/app/src/main/java/pbd/tubes/exe_android/data/api/ApiService.kt b/app/src/main/java/pbd/tubes/exe_android/data/api/ApiService.kt index 3302493c46b83913262d85de0773599a54e3172c..04dd7d2c2ada8bd4263d9b3095e7e12ab54dbaa9 100644 --- a/app/src/main/java/pbd/tubes/exe_android/data/api/ApiService.kt +++ b/app/src/main/java/pbd/tubes/exe_android/data/api/ApiService.kt @@ -1,15 +1,26 @@ package pbd.tubes.exe_android.data.api +import okhttp3.MultipartBody +import okhttp3.ResponseBody import pbd.tubes.exe_android.data.api.login.LoginRequest import pbd.tubes.exe_android.data.api.login.LoginResponse -import pbd.tubes.exe_android.data.api.upload.UploadImage +import retrofit2.Call import retrofit2.Response import retrofit2.http.Body +import retrofit2.http.Multipart import retrofit2.http.POST +import retrofit2.http.Part interface ApiService { @POST("/api/auth/login") - suspend fun login(@Body credentials: LoginRequest): Response<LoginResponse> + suspend fun login( + @Body credentials: LoginRequest + ): Response<LoginResponse> + + @Multipart @POST("/api/bill/upload") - suspend fun upload(@Body data: UploadImage): Response<LoginResponse> + suspend fun uploadImage( + @Part token : String, + @Part imageFile : MultipartBody.Part + ): Call<ResponseBody> } diff --git a/app/src/main/java/pbd/tubes/exe_android/data/api/upload/UploadImage.kt b/app/src/main/java/pbd/tubes/exe_android/data/api/upload/UploadImage.kt deleted file mode 100644 index e7e1018a59ac7d218b991a38df5273a87f19a316..0000000000000000000000000000000000000000 --- a/app/src/main/java/pbd/tubes/exe_android/data/api/upload/UploadImage.kt +++ /dev/null @@ -1,7 +0,0 @@ -package pbd.tubes.exe_android.data.api.upload - -import android.media.Image - -data class UploadImage( - val image: Image //or file? FIXME(key: image or file) -) diff --git a/app/src/main/java/pbd/tubes/exe_android/data/api/upload/UploadResponse.kt b/app/src/main/java/pbd/tubes/exe_android/data/api/upload/UploadResponse.kt new file mode 100644 index 0000000000000000000000000000000000000000..5179d141475c21b06040270c68dc90937d553646 --- /dev/null +++ b/app/src/main/java/pbd/tubes/exe_android/data/api/upload/UploadResponse.kt @@ -0,0 +1,10 @@ +package pbd.tubes.exe_android.data.api.upload + +class UploadResponse { + data class Item( + val name : String, + val qty : Int, + val price : Double, + ) + val list : List<Item> = listOf() +} \ No newline at end of file diff --git a/app/src/main/java/pbd/tubes/exe_android/ui/scan/ScanFragment.kt b/app/src/main/java/pbd/tubes/exe_android/ui/scan/ScanFragment.kt index fcc79a1869da0bfd543d4f7c1a230a607d86063b..621e2039bf185c9013b27408ffe598de64c992ef 100644 --- a/app/src/main/java/pbd/tubes/exe_android/ui/scan/ScanFragment.kt +++ b/app/src/main/java/pbd/tubes/exe_android/ui/scan/ScanFragment.kt @@ -2,7 +2,10 @@ package pbd.tubes.exe_android.ui.scan import android.Manifest import android.content.ContentValues +import android.content.Context +import android.content.Context.MODE_PRIVATE import android.content.pm.PackageManager +import android.net.Uri import android.os.Bundle import android.provider.MediaStore import android.util.Log @@ -10,26 +13,41 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.Toast +import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.camera.core.CameraSelector import androidx.camera.core.ImageAnalysis import androidx.camera.core.ImageCapture +import androidx.camera.core.ImageCapture.OutputFileResults import androidx.camera.core.ImageCaptureException -import androidx.camera.core.ImageProxy import androidx.camera.core.Preview import androidx.camera.lifecycle.ProcessCameraProvider import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import kotlinx.coroutines.launch +import okhttp3.MediaType +import okhttp3.MultipartBody +import okhttp3.OkHttpClient +import okhttp3.RequestBody +import okhttp3.ResponseBody +import pbd.tubes.exe_android.R +import pbd.tubes.exe_android.data.api.ApiService +import pbd.tubes.exe_android.data.api.upload.UploadResponse import pbd.tubes.exe_android.databinding.FragmentScanBinding -import java.nio.ByteBuffer +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.io.File import java.text.SimpleDateFormat import java.util.Locale import java.util.concurrent.ExecutorService import java.util.concurrent.Executors -typealias LumaListener = (luma: Double) -> Unit class ScanFragment : Fragment() { @@ -38,12 +56,34 @@ class ScanFragment : Fragment() { // This property is only valid between onCreateView and // onDestroyView. private val binding get() = _binding!! - private val viewModel: ScanViewModel by viewModels() - private var imageCapture: ImageCapture? = null + private val retrofit: Retrofit by lazy { + Retrofit.Builder() + .baseUrl(BASE_URL) + .client(OkHttpClient.Builder().build()) + .addConverterFactory(GsonConverterFactory.create()) + .build() + } + private val apiService: ApiService by lazy { + retrofit.create(ApiService::class.java) + } + private var imageCapture: ImageCapture? = null private lateinit var cameraExecutor: ExecutorService - + private var pickMedia: ActivityResultLauncher<PickVisualMediaRequest>? = null + override fun onAttach(context: Context) { + super.onAttach(context) + pickMedia = registerForActivityResult( + ActivityResultContracts.PickVisualMedia() + ) { + if (it != null){ + imagePreview(it) + Log.d("MyApp", "Picked picture, selected URI: $it") + } else { + Log.d("MyApp", "No picture picked") + } + } + } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -52,7 +92,6 @@ class ScanFragment : Fragment() { _binding = FragmentScanBinding.inflate(inflater, container, false) val root: View = binding.root - if (allPermissionsGranted()) { startCamera() } else { @@ -60,42 +99,129 @@ class ScanFragment : Fragment() { } binding.imageCaptureButton.setOnClickListener { takePhoto() } - + binding.pickImageButton.setOnClickListener{ imageChooser() } cameraExecutor = Executors.newSingleThreadExecutor() - -// val textView: TextView = binding.textScan -// viewModel.text.observe(viewLifecycleOwner) { -// textView.text = it -// } return root } + // Function to get the actual file path from URI + fun getPathFromUri(context: Context, uri: Uri): String? { + var filePath: String? = null + when (uri.scheme) { + // For "file" scheme URIs + "file" -> { + filePath = uri.path + } + // For "content" scheme URIs (API level 19 and above) + "content" -> { + filePath = + getImagePathFromUri(context, uri) + } + } + return filePath + } - // this function is triggered when - // the Select Image Button is clicked - fun imageChooser() { - registerForActivityResult( - ActivityResultContracts.PickVisualMedia() - ) { - if (it != null){ - Log.d("MyApp", "Picked picture, selected URI: $it") - } else { - Log.d("MyApp", "No picture picked") + // Function to get file path from content URI (API level 19 and above) + private fun getImagePathFromUri(context: Context, uri: Uri): String? { + val projection = arrayOf(MediaStore.Images.Media.DATA) + val cursor = context.contentResolver.query(uri, projection, null, null, null) + cursor?.use { + if (it.moveToFirst()) { + val columnIndex = it.getColumnIndexOrThrow(MediaStore.Images.Media.DATA) + return it.getString(columnIndex) + } + } + return null + } + private fun uploadImage(imageUri: Uri){ + lifecycleScope.launch { + val file = File(getPathFromUri(requireContext(), imageUri)!!) + val fileBody : RequestBody = RequestBody.create(MediaType.parse("image/*"), file) + val imagePart: MultipartBody.Part = MultipartBody.Part.createFormData("file", file.getName(), fileBody) + val sharedPreferences = requireContext().getSharedPreferences("user_session", MODE_PRIVATE) + try { + //TODO(implement network sensing/authentication sensing) + apiService.uploadImage( + sharedPreferences.getString("token", null)!!, + imagePart) + .enqueue(object : + Callback<ResponseBody>{ + override fun onResponse( + call: Call<ResponseBody>, + response: retrofit2.Response<ResponseBody> + ) { + if (response.isSuccessful) { +// parseResponse(response.body()) + Log.d("MyApp", response.body().toString()) + } else { + Log.d("MyApp", response.message()) + } + } + + override fun onFailure(call:Call<ResponseBody>, t: Throwable) { + Log.d("MyApp", t.toString()) + } + }) + } catch (e : Exception) { + Log.e("MyApp", e.message.toString()) } } - .launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) + Toast.makeText(requireContext(), "Image successfully uploaded", Toast.LENGTH_SHORT).show() + } + + private fun parseResponse(response: String) : List<UploadResponse.Item>{ + val gson = Gson() + val itemType = object : TypeToken<List<UploadResponse.Item>>() {}.type + return gson.fromJson(response, itemType) + } + + private fun imagePreview(imageUri: Uri){ + binding.imagePreview.setImageURI(imageUri) + binding.scanFragment.removeView(binding.viewFinder) + binding.imageCaptureButton.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.ic_done_24)) + binding.imageCaptureButton.setBackgroundColor(ContextCompat.getColor(requireContext(), R.color.pemasukan_color)) + binding.pickImageButton.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.ic_redo_24)) + binding.pickImageButton.setBackgroundColor(ContextCompat.getColor(requireContext(), R.color.pembelian_color)) + + binding.pickImageButton.setOnClickListener { + resetState() + } + binding.imageCaptureButton.setOnClickListener { + resetState() + uploadImage(imageUri) + } + } + + private fun resetState(){ + binding.scanFragment.addView(binding.viewFinder) + + binding.pickImageButton.background = ContextCompat.getDrawable(requireContext(), R.drawable.rounded_rectangle) + binding.pickImageButton.setImageDrawable(ContextCompat.getDrawable(requireContext(),R.drawable.ic_image_24)) + binding.pickImageButton.setOnClickListener { imageChooser() } + + binding.imagePreview.setImageDrawable(null) + binding.imagePreview.visibility = View.VISIBLE + + binding.imageCaptureButton.background = ContextCompat.getDrawable(requireContext(), R.drawable.camera_button) + binding.imageCaptureButton.setImageDrawable(null) + binding.imageCaptureButton.setOnClickListener { takePhoto() } + //TODO(Delete file from URI) + } + private fun imageChooser() { + pickMedia!!.launch(PickVisualMediaRequest( + ActivityResultContracts.PickVisualMedia.ImageOnly + )) } private fun takePhoto() { // Get a stable reference of the modifiable image capture use case val imageCapture = imageCapture ?: return - // Create time stamped name and MediaStore entry. - val name = SimpleDateFormat(FILENAME_FORMAT, Locale.ENGLISH) + name = SimpleDateFormat(FILENAME_FORMAT, Locale.ENGLISH) .format(System.currentTimeMillis()) val contentValues = ContentValues().apply { put(MediaStore.MediaColumns.DISPLAY_NAME, name) put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg") - put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/CameraX-Image") + put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/temp") } // Create output options object which contains file + metadata @@ -114,13 +240,10 @@ class ScanFragment : Fragment() { override fun onError(exc: ImageCaptureException) { Log.e(TAG, "Photo capture failed: ${exc.message}", exc) } - override fun - onImageSaved(output: ImageCapture.OutputFileResults){ - val msg = "Photo capture succeeded: ${output.savedUri}" - Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show() - // TODO(photo is then sent to server) - Log.d(TAG, msg) + onImageSaved(output: OutputFileResults){ + imagePreview(output.savedUri!!) + Log.d(TAG, "Photo capture success") } } ) @@ -145,11 +268,11 @@ class ScanFragment : Fragment() { val imageAnalyzer = ImageAnalysis.Builder() .build() - .also { - it.setAnalyzer(cameraExecutor, LuminosityAnalyzer { luma -> - Log.d(TAG, "Average luminosity: $luma") - }) - } +// .also { +// it.setAnalyzer(cameraExecutor, LuminosityAnalyzer { luma -> +// Log.d(TAG, "Average luminosity: $luma") +// }) +// } // Select back camera as a default val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA @@ -196,41 +319,42 @@ class ScanFragment : Fragment() { startCamera() } } - private class LuminosityAnalyzer(private val listener: LumaListener) : ImageAnalysis.Analyzer { - - private fun ByteBuffer.toByteArray(): ByteArray { - rewind() // Rewind the buffer to zero - val data = ByteArray(remaining()) - get(data) // Copy the buffer into a byte array - return data // Return the byte array - } - - override fun analyze(image: ImageProxy) { - - val buffer = image.planes[0].buffer - val data = buffer.toByteArray() - val pixels = data.map { it.toInt() and 0xFF } - val luma = pixels.average() - - listener(luma) - - image.close() - } +// private class LuminosityAnalyzer(private val listener: LumaListener) : ImageAnalysis.Analyzer { +// +// private fun ByteBuffer.toByteArray(): ByteArray { +// rewind() // Rewind the buffer to zero +// val data = ByteArray(remaining()) +// get(data) // Copy the buffer into a byte array +// return data // Return the byte array +// } +// +// override fun analyze(image: ImageProxy) { +// +// val buffer = image.planes[0].buffer +// val data = buffer.toByteArray() +// val pixels = data.map { it.toInt() and 0xFF } +// val luma = pixels.average() +// +// listener(luma) +// +// image.close() +// } +// } + override fun onDestroyView() { + super.onDestroyView() + _binding = null + cameraExecutor.shutdown() } companion object { + private const val BASE_URL = "https://pbd-backend-2024.vercel.app" private const val TAG = "MyApp" private const val FILENAME_FORMAT = "yyyy-MM-dd-HH-mm-ss-SSS" + private var name :String? = null private val REQUIRED_PERMISSIONS = mutableListOf ( Manifest.permission.CAMERA, ).apply { -// add(Manifest.permission.WRITE_EXTERNAL_STORAGE) //DEPRECATED, only for sdk <= 28 +// add(Manifest.permission.WRITE_EXTERNAL_STORAGE) //DEPRECATED, only for sdk <= 28, use the MediaStore.createWriteRequest intent }.toTypedArray() - // If you need to write to shared storage, use the MediaStore.createWriteRequest intent - } - override fun onDestroyView() { - super.onDestroyView() - _binding = null - cameraExecutor.shutdown() } } \ No newline at end of file diff --git a/app/src/main/res/drawable/camera_button.xml b/app/src/main/res/drawable/camera_button.xml index df8d6d46366386dcafd2c73d0686d94068212346..65e1847247697741490cc45d8f4f2ca80383bccc 100644 --- a/app/src/main/res/drawable/camera_button.xml +++ b/app/src/main/res/drawable/camera_button.xml @@ -1,9 +1,14 @@ <?xml version="1.0" encoding="utf-8"?> <selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item + android:state_pressed="true"> + <shape + android:shape="oval" + android:tint="@color/gray_200"/> + </item> <item> <shape - android:shape="oval"> - <solid android:color="@color/white" /> - </shape> + android:shape="oval" + android:tint="@color/white"/> </item> </selector> \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_done_24.xml b/app/src/main/res/drawable/ic_done_24.xml new file mode 100644 index 0000000000000000000000000000000000000000..adb7ae67ef8b5bfd3d948d55db5258bf39b5b0de --- /dev/null +++ b/app/src/main/res/drawable/ic_done_24.xml @@ -0,0 +1,5 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp"> + + <path android:fillColor="@android:color/white" android:pathData="M9,16.2L4.8,12l-1.4,1.4L9,19 21,7l-1.4,-1.4L9,16.2z"/> + +</vector> diff --git a/app/src/main/res/drawable/ic_redo_24.xml b/app/src/main/res/drawable/ic_redo_24.xml new file mode 100644 index 0000000000000000000000000000000000000000..39b0396e956a09945ec2e43475816b9ade25a6e6 --- /dev/null +++ b/app/src/main/res/drawable/ic_redo_24.xml @@ -0,0 +1,5 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp"> + + <path android:fillColor="@android:color/white" android:pathData="M18.4,10.6C16.55,8.99 14.15,8 11.5,8c-4.65,0 -8.58,3.03 -9.96,7.22L3.9,16c1.05,-3.19 4.05,-5.5 7.6,-5.5 1.95,0 3.73,0.72 5.12,1.88L13,16h9V7l-3.6,3.6z"/> + +</vector> diff --git a/app/src/main/res/drawable/rounded_rectangle.xml b/app/src/main/res/drawable/rounded_rectangle.xml index fa6d9e7627dd70bb44f23bbf40b14e6fca0ae12f..0c78b28c835b9eaa417801eb6872cc3bc006561e 100644 --- a/app/src/main/res/drawable/rounded_rectangle.xml +++ b/app/src/main/res/drawable/rounded_rectangle.xml @@ -1,7 +1,7 @@ <?xml version="1.0" encoding="utf-8"?> <shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle"> - <solid android:color="@android:color/transparent" /> + <solid android:color="@android:color/white" /> <corners android:radius="10dip" /> diff --git a/app/src/main/res/layout/fragment_scan.xml b/app/src/main/res/layout/fragment_scan.xml index e447eec72ee68eae145c4e61a7de0ec061c7d304..7fd955ab425a71c042772c8f244d77f301d7dc5d 100644 --- a/app/src/main/res/layout/fragment_scan.xml +++ b/app/src/main/res/layout/fragment_scan.xml @@ -2,6 +2,7 @@ <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/scan_fragment" android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".ui.scan.ScanFragment"> @@ -9,36 +10,53 @@ <androidx.camera.view.PreviewView android:id="@+id/viewFinder" android:layout_width="match_parent" - android:layout_height="match_parent" > + android:layout_height="match_parent"> </androidx.camera.view.PreviewView> <ImageButton android:id="@+id/image_capture_button" - android:background="@drawable/camera_button" android:layout_width="110dp" android:layout_height="110dp" android:layout_marginBottom="50dp" - android:elevation="2dp" + android:background="@drawable/camera_button" + android:contentDescription="@string/take_picture" + android:elevation="1dp" android:text="@string/take_photo" + android:scaleType="fitXY" + android:padding="24dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintLeft_toLeftOf="parent" - app:layout_constraintStart_toStartOf="parent" - android:contentDescription="@string/take_picture" /> + app:layout_constraintStart_toStartOf="parent" /> <ImageButton android:id="@+id/pick_image_button" android:layout_width="60dp" android:layout_height="60dp" android:layout_marginEnd="32dp" + android:background="@drawable/rounded_rectangle" + android:contentDescription="@string/pick_picture" + android:elevation="1dp" android:minWidth="50dp" android:minHeight="50dp" android:src="@drawable/ic_image_24" app:layout_constraintBottom_toBottomOf="@+id/image_capture_button" app:layout_constraintEnd_toStartOf="@+id/image_capture_button" - app:layout_constraintTop_toTopOf="@+id/image_capture_button" - android:contentDescription="@string/pick_picture" - /> + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="@+id/image_capture_button" /> + + <ImageView + android:id="@+id/image_preview" + android:layout_width="0dp" + android:layout_height="0dp" + android:layout_marginBottom="50dp" + android:contentDescription="@string/photo_preview" + android:elevation="2dp" + android:visibility="visible" + app:layout_constraintBottom_toTopOf="@+id/image_capture_button" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> </androidx.constraintlayout.widget.ConstraintLayout> \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0fb2613f2202f36b156fb47cbf4df4f4e3c4d2ac..2142b2877ac9f18fc59aa115acf9bcce18c8b566 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -15,4 +15,5 @@ <string name="take_photo">ambil foto</string> <string name="take_picture">Take Picture</string> <string name="pick_picture">Pick picture</string> + <string name="photo_preview">Photo Preview</string> </resources> \ No newline at end of file