diff --git a/app/build.gradle b/app/build.gradle index 069d7b33..4366e711 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -26,7 +26,7 @@ android { defaultConfig { applicationId "com.example.inventory" - minSdkVersion 19 + minSdkVersion 26 targetSdkVersion 33 versionCode 1 versionName "1.0" @@ -61,6 +61,9 @@ dependencies { implementation "androidx.core:core-ktx:$core_ktx_version" implementation "com.google.android.material:material:$material_version" + // Worker libraries + implementation "androidx.work:work-runtime-ktx:$work_version" + // Lifecycle libraries implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version" implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5d86cc63..a2627e24 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -15,11 +15,12 @@ --> + diff --git a/app/src/main/java/com/example/inventory/AddItemFragment.kt b/app/src/main/java/com/example/inventory/AddItemFragment.kt index 6fd3e38f..686bb5dd 100644 --- a/app/src/main/java/com/example/inventory/AddItemFragment.kt +++ b/app/src/main/java/com/example/inventory/AddItemFragment.kt @@ -15,8 +15,19 @@ */ package com.example.inventory +import android.app.Activity.RESULT_OK +import android.app.DatePickerDialog import android.content.Context.INPUT_METHOD_SERVICE +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.ImageDecoder +import android.net.Uri +import android.os.Build import android.os.Bundle +import android.provider.MediaStore +import android.text.Editable +import android.text.TextWatcher import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -28,6 +39,8 @@ import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import com.example.inventory.data.Item import com.example.inventory.databinding.FragmentAddItemBinding +import java.io.ByteArrayOutputStream +import java.util.* /** * Fragment to add or update an item in the Inventory database. @@ -52,6 +65,13 @@ class AddItemFragment : Fragment() { private var _binding: FragmentAddItemBinding? = null private val binding get() = _binding!! + // For file upload + private val pickImage = 100 + private var imagePath: Uri? = null + private var imageBitmap: Bitmap? = null + private var imageByte: ByteArray? = null + private var bos: ByteArrayOutputStream? = ByteArrayOutputStream(); + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -65,24 +85,65 @@ class AddItemFragment : Fragment() { * Returns true if the EditTexts are not empty */ private fun isEntryValid(): Boolean { - return viewModel.isEntryValid( - binding.itemName.text.toString(), - binding.itemPrice.text.toString(), - binding.itemCount.text.toString(), - ) + val nameValid = (viewModel.isEntryValid( + binding.name.text.toString() + )) + val expiryDateValid = (viewModel.isEntryValid( + binding.expiryDate.text.toString() + )) + val quantityValid = (viewModel.isEntryValid( + binding.quantity.text.toString() + )) + var formValid = true + if (!nameValid || !expiryDateValid || !quantityValid) { + formValid = false + if (!nameValid) { + binding.name.error = "ingredient name cannot be empty" + } + if (!expiryDateValid) { + binding.expiryDate.error = "expiry date cannot be empty" + } + if (!quantityValid) { + binding.quantity.error = "quantity cannot be empty" + } + + } + return formValid } /** * Binds views with the passed in [item] information. */ private fun bind(item: Item) { - val price = "%.2f".format(item.itemPrice) + + // Use the uploaded user image if this is the add screen, otherwise take from database + var loadImageByte = if (navigationArgs.itemId > 0) { + // Check if the user added an image with this item + if (item.imageByte == null) { + null + } else { + BitmapFactory.decodeByteArray(item.imageByte, 0, item.imageByte!!.size) + } + } else { + BitmapFactory.decodeByteArray(imageByte, 0, imageByte!!.size) + } + binding.apply { - itemName.setText(item.itemName, TextView.BufferType.SPANNABLE) - itemPrice.setText(price, TextView.BufferType.SPANNABLE) - itemCount.setText(item.quantityInStock.toString(), TextView.BufferType.SPANNABLE) + name.setText(item.name, TextView.BufferType.SPANNABLE) + expiryDate.setText(item.expiryDate, TextView.BufferType.SPANNABLE) + label.setText(item.label.toString(), TextView.BufferType.SPANNABLE) + quantity.setText(item.quantity.toString(), TextView.BufferType.SPANNABLE) + binding.imageView.setImageBitmap(loadImageByte) saveAction.setOnClickListener { updateItem() } } + + if (item.imageByte == null) { + binding.imageView.visibility = View.GONE + } else { + binding.imageView.visibility = View.VISIBLE + imageByte = item.imageByte + } + } /** @@ -91,9 +152,11 @@ class AddItemFragment : Fragment() { private fun addNewItem() { if (isEntryValid()) { viewModel.addNewItem( - binding.itemName.text.toString(), - binding.itemPrice.text.toString(), - binding.itemCount.text.toString(), + binding.name.text.toString(), + binding.expiryDate.text.toString(), + binding.label.text.toString(), + binding.quantity.text.toString(), + imageByte ) val action = AddItemFragmentDirections.actionAddItemFragmentToItemListFragment() findNavController().navigate(action) @@ -107,15 +170,96 @@ class AddItemFragment : Fragment() { if (isEntryValid()) { viewModel.updateItem( this.navigationArgs.itemId, - this.binding.itemName.text.toString(), - this.binding.itemPrice.text.toString(), - this.binding.itemCount.text.toString() + this.binding.name.text.toString(), + this.binding.expiryDate.text.toString(), + this.binding.label.text.toString(), + this.binding.quantity.text.toString(), + this.imageByte, ) val action = AddItemFragmentDirections.actionAddItemFragmentToItemListFragment() findNavController().navigate(action) } } + /** + * Calls the Date Picker pop up for setting expiry date. + */ + private fun callDatePicker() { + val expiryDate = binding.expiryDate + val calendar = Calendar.getInstance() + val year = calendar.get(Calendar.YEAR) + val month = calendar.get(Calendar.MONTH) + val day = calendar.get(Calendar.DAY_OF_MONTH) + + val dateSetListener = + DatePickerDialog.OnDateSetListener { _, year, monthOfYear, dayOfMonth -> + var month = (monthOfYear+1).toString() + var day = dayOfMonth.toString() + if (monthOfYear < 10) { + month = "0$month" + } + if (dayOfMonth < 10) { + day = "0$day" + } + val selectedDate = "$year-${month}-$day" + expiryDate.setText(selectedDate) + } + + val datePickerDialog = + DatePickerDialog(requireContext(), dateSetListener, year, month, day) + datePickerDialog.datePicker.minDate = calendar.timeInMillis + datePickerDialog.show() + } + + private val MAX_DECIMAL_PLACES = 2 // Set the maximum number of decimal places here + + private fun setUpQuantityText() { + val editQuantity = binding.quantity + + editQuantity.addTextChangedListener(object : TextWatcher { + override fun afterTextChanged(s: Editable?) { + // Nothing to do here + } + + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) { + // Nothing to do here + } + + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { + val decimalIndex = s?.indexOf(".") ?: -1 + + if (s != null) { + if (decimalIndex != -1 && s.length - decimalIndex - 1 > MAX_DECIMAL_PLACES) { + // Too many decimal places, remove the extra ones + val truncatedString = s.substring(0, decimalIndex + MAX_DECIMAL_PLACES + 1) + editQuantity.setText(truncatedString) + editQuantity.setSelection(truncatedString.length) + } + } + } + }) + } + + + // File upload: Stores the image the user selects from their gallery into the 'imagePath' variable + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (resultCode == RESULT_OK && requestCode == pickImage) { + imagePath = data?.data +// binding.imageView.setImageURI(imagePath) + imageBitmap = if (Build.VERSION.SDK_INT >= 28) { + val source = ImageDecoder.createSource(requireActivity().contentResolver,imagePath!!) + ImageDecoder.decodeBitmap(source) + } else { + MediaStore.Images.Media.getBitmap(requireActivity().contentResolver,imagePath!!) + } + binding.imageView.setImageBitmap(imageBitmap) + imageBitmap?.compress(Bitmap.CompressFormat.JPEG, 33, bos) + imageByte = bos?.toByteArray(); + binding.imageView.visibility = View.VISIBLE + } + } + /** * Called when the view is created. * The itemId Navigation argument determines the edit item or add new item. @@ -125,17 +269,39 @@ class AddItemFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + val expiryDate = binding.expiryDate + expiryDate.setOnFocusChangeListener { _, hasFocus -> + if (hasFocus) { + callDatePicker() + } + } + val quantity = binding.quantity + quantity.setOnFocusChangeListener { _, hasFocus -> + if (hasFocus) { + setUpQuantityText() + } + } val id = navigationArgs.itemId + + // Protocol for editing an existing item if (id > 0) { viewModel.retrieveItem(id).observe(this.viewLifecycleOwner) { selectedItem -> item = selectedItem bind(item) } + + // Protocol for adding a new item } else { binding.saveAction.setOnClickListener { addNewItem() } } + + // Opens the phone's gallery + binding.uploadPhoto.setOnClickListener{ + val gallery = Intent(Intent.ACTION_PICK, MediaStore.Images.Media.INTERNAL_CONTENT_URI) + startActivityForResult(gallery, pickImage) + } } /** diff --git a/app/src/main/java/com/example/inventory/InventoryViewModel.kt b/app/src/main/java/com/example/inventory/InventoryViewModel.kt index a04d5264..5cd49e00 100644 --- a/app/src/main/java/com/example/inventory/InventoryViewModel.kt +++ b/app/src/main/java/com/example/inventory/InventoryViewModel.kt @@ -16,13 +16,10 @@ package com.example.inventory -import androidx.lifecycle.LiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.asLiveData -import androidx.lifecycle.viewModelScope +import androidx.lifecycle.* import com.example.inventory.data.Item import com.example.inventory.data.ItemDao +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch /** @@ -30,15 +27,21 @@ import kotlinx.coroutines.launch * */ class InventoryViewModel(private val itemDao: ItemDao) : ViewModel() { - + val allItems: MutableLiveData> = MutableLiveData>() // Cache all items form the database using LiveData. - val allItems: LiveData> = itemDao.getItems().asLiveData() + // val allItems: LiveData> = itemDao.getItems().asLiveData() + + fun getItems(searchString: String=""){ + viewModelScope.launch(Dispatchers.IO) { + allItems.postValue(itemDao.getSearchedItems(searchString)) + } + } /** * Returns true if stock is available to sell, false otherwise. */ fun isStockAvailable(item: Item): Boolean { - return (item.quantityInStock > 0) + return (item.quantity > 0) } /** @@ -46,11 +49,13 @@ class InventoryViewModel(private val itemDao: ItemDao) : ViewModel() { */ fun updateItem( itemId: Int, - itemName: String, - itemPrice: String, - itemCount: String + name: String, + expiryDate: String, + label: String, + quantity: String, + imageByte: ByteArray?, ) { - val updatedItem = getUpdatedItemEntry(itemId, itemName, itemPrice, itemCount) + val updatedItem = getUpdatedItemEntry(itemId, name, expiryDate, label, quantity, imageByte) updateItem(updatedItem) } @@ -68,9 +73,17 @@ class InventoryViewModel(private val itemDao: ItemDao) : ViewModel() { * Decreases the stock by one unit and updates the database. */ fun sellItem(item: Item) { - if (item.quantityInStock > 0) { + if (item.quantity > 0) { // Decrease the quantity by 1 - val newItem = item.copy(quantityInStock = item.quantityInStock - 1) + val newItem = item.copy(quantity = item.quantity - 1) + updateItem(newItem) + } + } + + fun incrementItem(item: Item) { + if (item.quantity > 0) { + // Decrease the quantity by 1 + val newItem = item.copy(quantity = item.quantity + 1) updateItem(newItem) } } @@ -78,8 +91,14 @@ class InventoryViewModel(private val itemDao: ItemDao) : ViewModel() { /** * Inserts the new Item into database. */ - fun addNewItem(itemName: String, itemPrice: String, itemCount: String) { - val newItem = getNewItemEntry(itemName, itemPrice, itemCount) + fun addNewItem( + name: String, + expiryDate: String, + label: String, + quantity: String, + imageByte: ByteArray?, + ) { + val newItem = getNewItemEntry(name, expiryDate, label, quantity, imageByte) insertItem(newItem) } @@ -111,8 +130,8 @@ class InventoryViewModel(private val itemDao: ItemDao) : ViewModel() { /** * Returns true if the EditTexts are not empty */ - fun isEntryValid(itemName: String, itemPrice: String, itemCount: String): Boolean { - if (itemName.isBlank() || itemPrice.isBlank() || itemCount.isBlank()) { + fun isEntryValid(field: String): Boolean { + if (field.isBlank()) { return false } return true @@ -122,11 +141,19 @@ class InventoryViewModel(private val itemDao: ItemDao) : ViewModel() { * Returns an instance of the [Item] entity class with the item info entered by the user. * This will be used to add a new entry to the Inventory database. */ - private fun getNewItemEntry(itemName: String, itemPrice: String, itemCount: String): Item { + private fun getNewItemEntry( + name: String, + expiryDate: String, + label: String, + quantity: String, + imageByte: ByteArray?, + ): Item { return Item( - itemName = itemName, - itemPrice = itemPrice.toDouble(), - quantityInStock = itemCount.toInt() + name = name, + expiryDate = expiryDate, + label = label, + quantity = quantity.toDouble(), + imageByte = imageByte ) } @@ -136,15 +163,19 @@ class InventoryViewModel(private val itemDao: ItemDao) : ViewModel() { */ private fun getUpdatedItemEntry( itemId: Int, - itemName: String, - itemPrice: String, - itemCount: String + name: String, + expiryDate: String, + label: String, + quantity: String, + imageByte: ByteArray? ): Item { return Item( id = itemId, - itemName = itemName, - itemPrice = itemPrice.toDouble(), - quantityInStock = itemCount.toInt() + name = name, + expiryDate = expiryDate, + label = label, + quantity = quantity.toDouble(), + imageByte = imageByte ) } } diff --git a/app/src/main/java/com/example/inventory/ItemDetailFragment.kt b/app/src/main/java/com/example/inventory/ItemDetailFragment.kt index 9264b290..00073bbc 100644 --- a/app/src/main/java/com/example/inventory/ItemDetailFragment.kt +++ b/app/src/main/java/com/example/inventory/ItemDetailFragment.kt @@ -16,16 +16,24 @@ package com.example.inventory +import android.content.pm.PackageManager +import android.graphics.BitmapFactory import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import com.example.inventory.data.Item -import com.example.inventory.data.getFormattedPrice +import com.example.inventory.data.getDaysToExpiry +import com.example.inventory.data.hasExpired +import com.example.inventory.data.isConsumed import com.example.inventory.databinding.FragmentItemDetailBinding import com.google.android.material.dialog.MaterialAlertDialogBuilder @@ -45,6 +53,8 @@ class ItemDetailFragment : Fragment() { private var _binding: FragmentItemDetailBinding? = null private val binding get() = _binding!! + private var notificationId: Int = 0 + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -58,14 +68,47 @@ class ItemDetailFragment : Fragment() { * Binds views with the passed in item data. */ private fun bind(item: Item) { + + // Only try to load the image if the user added one + var loadImageByte = if (item.imageByte == null) { + null + } else { + BitmapFactory.decodeByteArray(item.imageByte, 0, item.imageByte!!.size) + } + binding.apply { - itemName.text = item.itemName - itemPrice.text = item.getFormattedPrice() - itemCount.text = item.quantityInStock.toString() - sellItem.isEnabled = viewModel.isStockAvailable(item) - sellItem.setOnClickListener { viewModel.sellItem(item) } + name.text = item.name + expiryDate.text = item.expiryDate + label.text = item.label.toString() + quantity.text = item.quantity.toString() + decrementItem.isEnabled = viewModel.isStockAvailable(item) + incrementItem.isEnabled = viewModel.isStockAvailable(item) + decrementItem.setOnClickListener { + viewModel.sellItem(item) + if (item.quantity <= 1) { + showConfirmationDialog() + } + } + incrementItem.setOnClickListener { viewModel.incrementItem(item) } deleteItem.setOnClickListener { showConfirmationDialog() } + sendNotification.setOnClickListener { sendNotification() } editItem.setOnClickListener { editItem() } + binding.imageView.setImageBitmap(loadImageByte) + } + + if (item.imageByte == null) { + binding.imageView.visibility = View.GONE + } else { + binding.imageView.visibility = View.VISIBLE + } + + binding.message.visibility = View.VISIBLE + if (item.hasExpired()) { + binding.message.text = getString(R.string.expiry_hint) + } else if (item.isConsumed()) { + binding.message.text = getString(R.string.consumed_hint) + } else { + binding.message.visibility = View.GONE } } @@ -103,6 +146,34 @@ class ItemDetailFragment : Fragment() { findNavController().navigateUp() } + /** + * Sends a push notification to inform the user of the expiry date + */ + private fun sendNotification() { + if (ContextCompat.checkSelfPermission( + this@ItemDetailFragment.requireContext(), + android.Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + ) { + ActivityCompat.requestPermissions( + this@ItemDetailFragment.requireActivity(), + arrayOf(android.Manifest.permission.POST_NOTIFICATIONS), + 1 + ) + } else { + val builder = NotificationCompat.Builder(this.requireContext(), MainActivity.CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notifications) + .setContentTitle(getString(R.string.expiring_soon)) + .setContentText(getString(R.string.expiration_message, item.name, item.getDaysToExpiry())) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + + with(NotificationManagerCompat.from(this.requireContext())) { + // notificationId is a unique int for each notification that you must define + notify(notificationId++, builder.build()) + } + } + } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val id = navigationArgs.itemId diff --git a/app/src/main/java/com/example/inventory/ItemListAdapter.kt b/app/src/main/java/com/example/inventory/ItemListAdapter.kt index 0b9ae923..1007af2c 100644 --- a/app/src/main/java/com/example/inventory/ItemListAdapter.kt +++ b/app/src/main/java/com/example/inventory/ItemListAdapter.kt @@ -15,13 +15,17 @@ */ package com.example.inventory +//import com.example.inventory.data.getFormattedPrice import android.view.LayoutInflater import android.view.ViewGroup +import androidx.core.content.ContextCompat import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import com.example.inventory.data.Item -import com.example.inventory.data.getFormattedPrice +import com.example.inventory.data.getDaysToExpiry +import com.example.inventory.data.hasExpired +import com.example.inventory.data.isConsumed import com.example.inventory.databinding.ItemListItemBinding /** @@ -53,12 +57,24 @@ class ItemListAdapter(private val onItemClicked: (Item) -> Unit) : RecyclerView.ViewHolder(binding.root) { fun bind(item: Item) { - binding.itemName.text = item.itemName - binding.itemPrice.text = item.getFormattedPrice() - binding.itemQuantity.text = item.quantityInStock.toString() + binding.name.text = item.name + val expiredDays = item.getDaysToExpiry().toString() + val formatExpiredDays = "$expiredDays days" + binding.expiryDate.text = formatExpiredDays + + if (item.hasExpired()) { + binding.card.setCardBackgroundColor(ContextCompat.getColor(binding.card.context, R.color.ingredient_expired)) + } + if (item.isConsumed()) { + binding.name.setTextColor(ContextCompat.getColor(binding.card.context, R.color.ingredient_consumed)) + binding.expiryDate.setTextColor(ContextCompat.getColor(binding.card.context, R.color.ingredient_consumed)) + } } } + + + companion object { private val DiffCallback = object : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean { @@ -66,7 +82,7 @@ class ItemListAdapter(private val onItemClicked: (Item) -> Unit) : } override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean { - return oldItem.itemName == newItem.itemName + return oldItem.name == newItem.name } } } diff --git a/app/src/main/java/com/example/inventory/ItemListFragment.kt b/app/src/main/java/com/example/inventory/ItemListFragment.kt index 2ebb821c..1e01cd53 100644 --- a/app/src/main/java/com/example/inventory/ItemListFragment.kt +++ b/app/src/main/java/com/example/inventory/ItemListFragment.kt @@ -16,10 +16,13 @@ package com.example.inventory +import android.content.Intent +import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.SearchView import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.findNavController @@ -72,5 +75,32 @@ class ItemListFragment : Fragment() { ) this.findNavController().navigate(action) } + + binding.grocerySearchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener, + androidx.appcompat.widget.SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(p0: String?): Boolean { + return true + } + + override fun onQueryTextChange(p0: String?): Boolean { + p0?.let { + viewModel.getItems(p0) + } + return true + } + + }) + + binding.foodBankButton.setOnClickListener { + val gmmIntentUri = Uri.parse("geo:0,0?q=food donation") + val mapIntent = Intent(Intent.ACTION_VIEW, gmmIntentUri) + mapIntent.setPackage("com.google.android.apps.maps") + startActivity(mapIntent) + } + } + + override fun onResume() { + super.onResume() + viewModel.getItems() } } diff --git a/app/src/main/java/com/example/inventory/MainActivity.kt b/app/src/main/java/com/example/inventory/MainActivity.kt index a70a3a36..88e81bae 100644 --- a/app/src/main/java/com/example/inventory/MainActivity.kt +++ b/app/src/main/java/com/example/inventory/MainActivity.kt @@ -15,14 +15,30 @@ */ package com.example.inventory +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build import android.os.Bundle import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat import androidx.navigation.NavController import androidx.navigation.fragment.NavHostFragment import androidx.navigation.ui.NavigationUI.setupActionBarWithNavController +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import com.example.inventory.workers.NotificationWorker +import java.util.concurrent.TimeUnit class MainActivity : AppCompatActivity(R.layout.activity_main) { + companion object { + const val CHANNEL_ID: String = "Expirations" + const val WORK_ID: String = "NotificationWorker" + } + private lateinit var navController: NavController override fun onCreate(savedInstanceState: Bundle?) { @@ -34,6 +50,11 @@ class MainActivity : AppCompatActivity(R.layout.activity_main) { navController = navHostFragment.navController // Set up the action bar for use with the NavController setupActionBarWithNavController(this, navController) + + // Set up notifications channel and service + if (ContextCompat.checkSelfPermission(this, android.Manifest.permission.POST_NOTIFICATIONS ) == PackageManager.PERMISSION_GRANTED) { + setupNotifications() + } } /** @@ -42,4 +63,28 @@ class MainActivity : AppCompatActivity(R.layout.activity_main) { override fun onSupportNavigateUp(): Boolean { return navController.navigateUp() || super.onSupportNavigateUp() } + + /** + * Setup notifications + */ + private fun setupNotifications() { + // Create the NotificationChannel, but only on API 26+ because + // the NotificationChannel class is new and not in the support library + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val name = getString(R.string.channel_name) + val descriptionText = getString(R.string.channel_description) + val importance = NotificationManager.IMPORTANCE_DEFAULT + val channel = NotificationChannel(CHANNEL_ID, name, importance).apply { + description = descriptionText + } + // Register the channel with the system + val notificationManager: NotificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) + } + + // Setup notification service if not already running + val notificationsRequest = PeriodicWorkRequestBuilder(12, TimeUnit.HOURS).build() + WorkManager.getInstance(this).enqueueUniquePeriodicWork(WORK_ID, ExistingPeriodicWorkPolicy.KEEP, notificationsRequest) + } + } diff --git a/app/src/main/java/com/example/inventory/data/Item.kt b/app/src/main/java/com/example/inventory/data/Item.kt index 25440b4e..72d316de 100644 --- a/app/src/main/java/com/example/inventory/data/Item.kt +++ b/app/src/main/java/com/example/inventory/data/Item.kt @@ -18,7 +18,10 @@ package com.example.inventory.data import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey -import java.text.NumberFormat +import java.text.SimpleDateFormat +import java.time.LocalDateTime +import java.util.* +import java.util.concurrent.TimeUnit /** * Entity data class represents a single row in the database. @@ -27,15 +30,65 @@ import java.text.NumberFormat data class Item( @PrimaryKey(autoGenerate = true) val id: Int = 0, - @ColumnInfo(name = "name") - val itemName: String, - @ColumnInfo(name = "price") - val itemPrice: Double, @ColumnInfo(name = "quantity") - val quantityInStock: Int, -) + val quantity: Double, + @ColumnInfo(name = "name", defaultValue = "Apple") + val name: String = "Apple", + @ColumnInfo(name = "expiryDate", defaultValue = "1680291840000" /* default = March 31 2023*/) + val expiryDate: String = "expiry string", + @ColumnInfo(name = "label", defaultValue = "") + val label: String = "", + @ColumnInfo(name = "image", defaultValue = "") + val imageByte: ByteArray?, + @ColumnInfo(name = "discarded", defaultValue = false.toString()) + val discarded: Boolean = false, + @ColumnInfo(name = "addedOn", defaultValue = "1678394640000" /* default = March 1 2023*/) + val addedOn: Long = 1678394640000, + @ColumnInfo(name = "updatedOn", defaultValue = "1678394640000" /* default = March 1 2023*/) + val updatedOn: Long = 1678394640000, +) { + // Processing for ImagePath property (as it's a ByteArray) + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as Item + if (!imageByte.contentEquals(other.imageByte)) return false + return true + } + + override fun hashCode(): Int { + return imageByte.contentHashCode() + } +} /** * Returns the passed in price in currency format. */ -fun Item.getFormattedPrice(): String = - NumberFormat.getCurrencyInstance().format(itemPrice) \ No newline at end of file +//fun Item.getFormattedPrice(): String = +// NumberFormat.getCurrencyInstance().format(itemPrice) + +/** + * Returns the remaining number of days until the ingredient expired. + */ +fun Item.getDaysToExpiry(): Long { + val formatter = SimpleDateFormat("yyyy-MM-dd", Locale.ENGLISH) + val parsedExpiryDate = formatter.parse(expiryDate) + val parsedCurrentDate = formatter.parse(LocalDateTime.now().toString()) + val diffInMillies: Long = parsedExpiryDate.time - parsedCurrentDate.time + val diffInDays: Long = TimeUnit.DAYS.convert(diffInMillies, TimeUnit.MILLISECONDS) + + return diffInDays +} + +/** + * Returns boolean indicating if item has been consumed or not. + */ +fun Item.isConsumed(): Boolean { + return quantity <= 0 +} + +/** + * Returns boolean indicating if item has expired or not. + */ +fun Item.hasExpired(): Boolean { + return getDaysToExpiry() < 0 +} \ No newline at end of file diff --git a/app/src/main/java/com/example/inventory/data/ItemDao.kt b/app/src/main/java/com/example/inventory/data/ItemDao.kt index 08ef0b4f..0fdb867d 100644 --- a/app/src/main/java/com/example/inventory/data/ItemDao.kt +++ b/app/src/main/java/com/example/inventory/data/ItemDao.kt @@ -28,10 +28,13 @@ import kotlinx.coroutines.flow.Flow */ @Dao interface ItemDao { - + // @Query("SELECT * from item where name like '%'||:searchText||'%'") @Query("SELECT * from item ORDER BY name ASC") fun getItems(): Flow> + @Query("SELECT * from item where name like '%'||:searchText||'%'") + fun getSearchedItems(searchText:String):List + @Query("SELECT * from item WHERE id = :id") fun getItem(id: Int): Flow diff --git a/app/src/main/java/com/example/inventory/data/ItemRoomDatabase.kt b/app/src/main/java/com/example/inventory/data/ItemRoomDatabase.kt index f0a0d010..33a822f1 100644 --- a/app/src/main/java/com/example/inventory/data/ItemRoomDatabase.kt +++ b/app/src/main/java/com/example/inventory/data/ItemRoomDatabase.kt @@ -24,7 +24,7 @@ import androidx.room.RoomDatabase /** * Database class with a singleton INSTANCE object. */ -@Database(entities = [Item::class], version = 1, exportSchema = false) +@Database(entities = [Item::class], version = 8, exportSchema = false) abstract class ItemRoomDatabase : RoomDatabase() { abstract fun itemDao(): ItemDao diff --git a/app/src/main/java/com/example/inventory/workers/NotificationWorker.kt b/app/src/main/java/com/example/inventory/workers/NotificationWorker.kt new file mode 100644 index 00000000..c0237675 --- /dev/null +++ b/app/src/main/java/com/example/inventory/workers/NotificationWorker.kt @@ -0,0 +1,44 @@ +package com.example.inventory.workers + +import android.annotation.SuppressLint +import android.content.Context +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.example.inventory.MainActivity +import com.example.inventory.R +import com.example.inventory.data.Item +import com.example.inventory.data.ItemRoomDatabase +import com.example.inventory.data.getDaysToExpiry +import kotlinx.coroutines.flow.first + +class NotificationWorker(appContext: Context, workerParams: WorkerParameters): + CoroutineWorker(appContext, workerParams) { + + @SuppressLint("MissingPermission") + override suspend fun doWork(): Result { + Log.d("worker", "running task") + val items: List = ItemRoomDatabase.getDatabase(applicationContext).itemDao().getItems().first() + items.forEach { item:Item -> + if (item.getDaysToExpiry() <= 1) { + Log.d("worker", "${item.name} expires in ${item.getDaysToExpiry()} days") + val builder = NotificationCompat.Builder(applicationContext, MainActivity.CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notifications) + .setContentTitle(applicationContext.getString(R.string.expiring_soon)) + .setContentText(applicationContext.getString(R.string.expiration_message, item.name, item.getDaysToExpiry())) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + + with(NotificationManagerCompat.from(applicationContext)) { + // notificationId is a unique int for each notification that you must define + notify(item.id, builder.build()) + } + } + } + + Log.d("worker", "finished checking") + // Indicate whether the work finished successfully with the Result + return Result.success() + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/baseline_image_search_24.xml b/app/src/main/res/drawable/baseline_image_search_24.xml new file mode 100644 index 00000000..d797b739 --- /dev/null +++ b/app/src/main/res/drawable/baseline_image_search_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_foodbank.xml b/app/src/main/res/drawable/ic_foodbank.xml new file mode 100644 index 00000000..90c437b7 --- /dev/null +++ b/app/src/main/res/drawable/ic_foodbank.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_notifications.xml b/app/src/main/res/drawable/ic_notifications.xml new file mode 100644 index 00000000..1d038a44 --- /dev/null +++ b/app/src/main/res/drawable/ic_notifications.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/layout/fragment_add_item.xml b/app/src/main/res/layout/fragment_add_item.xml index 051ef747..da2799d9 100644 --- a/app/src/main/res/layout/fragment_add_item.xml +++ b/app/src/main/res/layout/fragment_add_item.xml @@ -25,70 +25,115 @@ android:layout_margin="@dimen/margin"> + + app:layout_constraintTop_toBottomOf="@+id/name_label"> + app:layout_constraintTop_toBottomOf="@+id/expiry_date_label"> + + + + + + + +