Project: Build All Screens
Step 6: Add / Edit note screen
<?xml version="1.0" encoding="utf-8"?>
<!-- NestedScrollView: the form scrolls when the keyboard is open -->
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:id="@+id/form"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilTitle"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/hint_title"
app:counterEnabled="true"
app:counterMaxLength="60"
app:startIconDrawable="@drawable/ic_note">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textCapSentences"
android:maxLength="60" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="@string/hint_content">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etContent"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="top"
android:inputType="textMultiLine|textCapSentences"
android:minLines="4" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Exposed dropdown menu (Material version of a Spinner) -->
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.Material3.TextInputLayout.OutlinedBox.ExposedDropdownMenu"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="@string/hint_subject">
<com.google.android.material.textfield.MaterialAutoCompleteTextView
android:id="@+id/actvSubject"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textCapWords" />
</com.google.android.material.textfield.TextInputLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/priority"
android:textAppearance="?attr/textAppearanceTitleSmall" />
<com.google.android.material.chip.ChipGroup
android:id="@+id/chipGroupPriority"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:selectionRequired="true"
app:singleSelection="true">
<com.google.android.material.chip.Chip
android:id="@+id/chipLow"
style="@style/Widget.Material3.Chip.Filter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/priority_low" />
<com.google.android.material.chip.Chip
android:id="@+id/chipMedium"
style="@style/Widget.Material3.Chip.Filter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/priority_medium" />
<com.google.android.material.chip.Chip
android:id="@+id/chipHigh"
style="@style/Widget.Material3.Chip.Filter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/priority_high" />
</com.google.android.material.chip.ChipGroup>
<com.google.android.material.checkbox.MaterialCheckBox
android:id="@+id/cbFavorite"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/add_to_favourites" />
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switchReminder"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/reminder" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnPickDate"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/pick_date"
android:visibility="gone"
app:icon="@drawable/ic_alarm" />
<ImageView
android:id="@+id/ivPhoto"
android:layout_width="match_parent"
android:layout_height="200dp"
android:layout_marginTop="16dp"
android:contentDescription="@string/photo"
android:scaleType="centerCrop"
android:visibility="gone" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnAddPhoto"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/add_photo"
app:icon="@drawable/ic_image" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnRemovePhoto"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="@string/remove_photo"
android:visibility="gone" />
</LinearLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnSave"
android:layout_width="match_parent"
android:layout_height="56dp"
android:layout_marginTop="24dp"
android:text="@string/save_note" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>package com.codingeasily.studentdiary.ui.editor
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.Toast
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.view.MenuProvider
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import com.bumptech.glide.Glide
import com.codingeasily.studentdiary.R
import com.codingeasily.studentdiary.data.Note
import com.codingeasily.studentdiary.databinding.FragmentAddEditNoteBinding
import com.codingeasily.studentdiary.ui.NoteViewModel
import com.codingeasily.studentdiary.util.DateUtils
import com.codingeasily.studentdiary.util.ImageStore
import com.google.android.material.datepicker.MaterialDatePicker
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.timepicker.MaterialTimePicker
import com.google.android.material.timepicker.TimeFormat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.util.Calendar
import java.util.TimeZone
/** Add a new note (noteId = -1) or edit an existing one. */
class AddEditNoteFragment : Fragment() {
private var _binding: FragmentAddEditNoteBinding? = null
private val binding get() = _binding!!
private val viewModel: NoteViewModel by activityViewModels()
private var existing: Note? = null
private var dueAt: Long? = null
private var imagePath: String? = null
// Android Photo Picker – no storage permission needed
private val pickImage = registerForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri: Uri? ->
if (uri != null) copyImage(uri)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentAddEditNoteBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// Subject dropdown (exposed dropdown menu = AutoCompleteTextView)
val subjects = resources.getStringArray(R.array.subjects)
binding.actvSubject.setAdapter(ArrayAdapter(requireContext(), android.R.layout.simple_list_item_1, subjects))
binding.switchReminder.setOnCheckedChangeListener { _, checked ->
binding.btnPickDate.isVisible = checked
if (checked && dueAt == null) pickDateTime()
}
binding.btnPickDate.setOnClickListener { pickDateTime() }
binding.btnAddPhoto.setOnClickListener {
pickImage.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
}
binding.btnRemovePhoto.setOnClickListener { imagePath = null; showImage() }
binding.btnSave.setOnClickListener { save() }
val noteId = arguments?.getLong(ARG_NOTE_ID, -1L) ?: -1L
if (noteId != -1L) {
viewLifecycleOwner.lifecycleScope.launch {
viewModel.getNote(noteId)?.let { fill(it) }
}
addDeleteMenu()
} else {
binding.actvSubject.setText(subjects.first(), false)
binding.chipMedium.isChecked = true
}
}
private fun fill(note: Note) {
existing = note
binding.etTitle.setText(note.title)
binding.etContent.setText(note.content)
binding.actvSubject.setText(note.subject, false)
when (note.priority) {
Note.PRIORITY_HIGH -> binding.chipHigh.isChecked = true
Note.PRIORITY_LOW -> binding.chipLow.isChecked = true
else -> binding.chipMedium.isChecked = true
}
binding.cbFavorite.isChecked = note.isFavorite
dueAt = note.dueAt
binding.switchReminder.isChecked = note.dueAt != null
showDue()
imagePath = note.imagePath
showImage()
}
/** MaterialDatePicker → MaterialTimePicker → dueAt */
private fun pickDateTime() {
val datePicker = MaterialDatePicker.Builder.datePicker()
.setTitleText(R.string.pick_date)
.setSelection(MaterialDatePicker.todayInUtcMilliseconds())
.build()
datePicker.addOnPositiveButtonClickListener { utcMillis ->
// The date picker returns midnight UTC – convert to a local calendar date
val utc = Calendar.getInstance(TimeZone.getTimeZone("UTC")).apply { timeInMillis = utcMillis }
val timePicker = MaterialTimePicker.Builder()
.setTimeFormat(TimeFormat.CLOCK_12H)
.setHour(9)
.setMinute(0)
.setTitleText(R.string.pick_time)
.build()
timePicker.addOnPositiveButtonClickListener {
val local = Calendar.getInstance().apply {
set(utc.get(Calendar.YEAR), utc.get(Calendar.MONTH), utc.get(Calendar.DAY_OF_MONTH),
timePicker.hour, timePicker.minute, 0)
set(Calendar.MILLISECOND, 0)
}
dueAt = local.timeInMillis
showDue()
}
timePicker.show(childFragmentManager, "time")
}
datePicker.addOnNegativeButtonClickListener { if (dueAt == null) binding.switchReminder.isChecked = false }
datePicker.show(childFragmentManager, "date")
}
private fun showDue() {
val due = dueAt
binding.btnPickDate.isVisible = binding.switchReminder.isChecked
binding.btnPickDate.text = if (due != null) DateUtils.format(due) else getString(R.string.pick_date)
}
private fun copyImage(uri: Uri) {
viewLifecycleOwner.lifecycleScope.launch {
val path = withContext(Dispatchers.IO) { ImageStore.copyToAppStorage(requireContext(), uri) }
if (path == null) {
Toast.makeText(requireContext(), R.string.image_error, Toast.LENGTH_SHORT).show()
} else {
imagePath = path
showImage()
}
}
}
private fun showImage() {
val path = imagePath
binding.ivPhoto.isVisible = path != null
binding.btnRemovePhoto.isVisible = path != null
if (path != null) Glide.with(this).load(File(path)).centerCrop().into(binding.ivPhoto)
}
private fun save() {
val title = binding.etTitle.text?.toString()?.trim().orEmpty()
if (title.isEmpty()) {
binding.tilTitle.error = getString(R.string.error_title)
return
}
binding.tilTitle.error = null
val reminderOn = binding.switchReminder.isChecked
val due = dueAt
if (reminderOn && (due == null || due < System.currentTimeMillis())) {
Snackbar.make(binding.root, R.string.error_due, Snackbar.LENGTH_LONG).show()
return
}
val priority = when (binding.chipGroupPriority.checkedChipId) {
R.id.chipHigh -> Note.PRIORITY_HIGH
R.id.chipLow -> Note.PRIORITY_LOW
else -> Note.PRIORITY_MEDIUM
}
val base = existing ?: Note(title = title)
val note = base.copy(
title = title,
content = binding.etContent.text?.toString()?.trim().orEmpty(),
subject = binding.actvSubject.text.toString().ifBlank { "General" },
priority = priority,
dueAt = if (reminderOn) due else null,
isFavorite = binding.cbFavorite.isChecked,
imagePath = imagePath
)
binding.btnSave.isEnabled = false
viewModel.save(note) {
if (!isAdded) return@save // user already left the screen
Toast.makeText(requireContext(), R.string.saved, Toast.LENGTH_SHORT).show()
findNavController().navigateUp()
}
}
private fun addDeleteMenu() {
requireActivity().addMenuProvider(object : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.menu_editor, menu)
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
if (menuItem.itemId != R.id.action_delete) return false
val note = existing ?: return true
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.delete_note)
.setMessage(getString(R.string.delete_confirm, note.title))
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.delete) { _, _ ->
viewModel.delete(note)
ImageStore.delete(note.imagePath)
findNavController().navigateUp()
}
.show()
return true
}
}, viewLifecycleOwner, Lifecycle.State.RESUMED)
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
companion object {
const val ARG_NOTE_ID = "noteId"
}
}package com.codingeasily.studentdiary.util
import android.content.Context
import android.net.Uri
import java.io.File
import java.util.UUID
/**
* The photo picker gives a temporary content:// Uri.
* We copy the picture into the app's private folder so it stays available.
*/
object ImageStore {
fun copyToAppStorage(context: Context, uri: Uri): String? = try {
val dir = File(context.filesDir, "images").apply { mkdirs() }
val file = File(dir, "${UUID.randomUUID()}.jpg")
context.contentResolver.openInputStream(uri)?.use { input ->
file.outputStream().use { output -> input.copyTo(output) }
}
file.absolutePath
} catch (e: Exception) {
null
}
fun delete(path: String?) {
if (path != null) File(path).delete()
}
}Step 7: Reminders
package com.codingeasily.studentdiary.reminder
import android.content.Context
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.workDataOf
import java.util.concurrent.TimeUnit
object ReminderScheduler {
private fun workName(noteId: Long) = "reminder_$noteId"
/** Schedules (or re-schedules) one reminder for a note. */
fun schedule(context: Context, noteId: Long, atMillis: Long) {
val delay = atMillis - System.currentTimeMillis()
if (delay <= 0) return
val request = OneTimeWorkRequestBuilder<ReminderWorker>()
.setInitialDelay(delay, TimeUnit.MILLISECONDS)
.setInputData(workDataOf(ReminderWorker.KEY_NOTE_ID to noteId))
.build()
WorkManager.getInstance(context)
.enqueueUniqueWork(workName(noteId), ExistingWorkPolicy.REPLACE, request)
}
fun cancel(context: Context, noteId: Long) {
WorkManager.getInstance(context).cancelUniqueWork(workName(noteId))
}
}package com.codingeasily.studentdiary.reminder
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.codingeasily.studentdiary.R
import com.codingeasily.studentdiary.data.AppDatabase
import com.codingeasily.studentdiary.data.SettingsStore
/** WorkManager runs this at the reminder time, even if the app was closed. */
class ReminderWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val noteId = inputData.getLong(KEY_NOTE_ID, -1L)
if (noteId == -1L || !SettingsStore(applicationContext).remindersEnabled) return Result.success()
val note = AppDatabase.get(applicationContext).noteDao().getById(noteId)
?: return Result.success() // note was deleted
if (note.isDone) return Result.success()
NotificationHelper.show(
applicationContext,
note.id,
applicationContext.getString(R.string.reminder_title, note.title),
note.content.ifBlank { note.subject }
)
return Result.success()
}
companion object {
const val KEY_NOTE_ID = "note_id"
}
}NotificationHelper.kt is explained line by line in the Notifications lesson.
Step 8: Subjects grid
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="6dp"
app:cardCornerRadius="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:id="@+id/tvLetter"
android:layout_width="56dp"
android:layout_height="56dp"
android:background="@drawable/bg_circle_brand"
android:gravity="center"
android:textColor="@android:color/white"
android:textSize="24sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvSubject"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:textAppearance="?attr/textAppearanceTitleMedium" />
<TextView
android:id="@+id/tvCount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodySmall" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>package com.codingeasily.studentdiary.ui.subjects
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.codingeasily.studentdiary.R
import com.codingeasily.studentdiary.data.SubjectCount
import com.codingeasily.studentdiary.databinding.ItemSubjectBinding
class SubjectAdapter(private val onClick: (SubjectCount) -> Unit) :
ListAdapter<SubjectCount, SubjectAdapter.VH>(DIFF) {
class VH(val binding: ItemSubjectBinding) : RecyclerView.ViewHolder(binding.root)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
VH(ItemSubjectBinding.inflate(LayoutInflater.from(parent.context), parent, false))
override fun onBindViewHolder(holder: VH, position: Int) {
val item = getItem(position)
with(holder.binding) {
tvLetter.text = item.subject.take(1).uppercase()
tvSubject.text = item.subject
tvCount.text = root.resources.getQuantityString(R.plurals.notes_count, item.total, item.total)
root.setOnClickListener { onClick(item) }
}
}
companion object {
private val DIFF = object : DiffUtil.ItemCallback<SubjectCount>() {
override fun areItemsTheSame(a: SubjectCount, b: SubjectCount) = a.subject == b.subject
override fun areContentsTheSame(a: SubjectCount, b: SubjectCount) = a == b
}
}
}package com.codingeasily.studentdiary.ui.subjects
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.os.bundleOf
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.GridLayoutManager
import com.codingeasily.studentdiary.R
import com.codingeasily.studentdiary.databinding.FragmentSubjectsBinding
import com.codingeasily.studentdiary.ui.NoteFilter
import com.codingeasily.studentdiary.ui.NoteViewModel
import com.codingeasily.studentdiary.ui.notes.NoteListFragment
/** Grid of subjects with note counts (GROUP BY query). */
class SubjectsFragment : Fragment() {
private var _binding: FragmentSubjectsBinding? = null
private val binding get() = _binding!!
private val viewModel: NoteViewModel by activityViewModels()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentSubjectsBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val adapter = SubjectAdapter { item ->
findNavController().navigate(
R.id.action_subjects_to_subjectNotes,
bundleOf(
NoteListFragment.ARG_FILTER to NoteFilter.SUBJECT.name,
NoteListFragment.ARG_SUBJECT to item.subject
)
)
}
binding.recyclerView.layoutManager = GridLayoutManager(requireContext(), 2)
binding.recyclerView.adapter = adapter
viewModel.subjectCounts.observe(viewLifecycleOwner) { list ->
adapter.submitList(list)
binding.emptyView.isVisible = list.isEmpty()
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}Step 9: Progress + tip of the day
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<!-- three number boxes in a row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:baselineAligned="false"
android:orientation="horizontal">
<com.google.android.material.card.MaterialCardView
style="@style/StatCard"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1">
<LinearLayout style="@style/StatBox">
<TextView android:id="@+id/tvTotal" style="@style/StatNumber" />
<TextView style="@style/StatLabel" android:text="@string/stat_total" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
style="@style/StatCard"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="8dp"
android:layout_weight="1">
<LinearLayout style="@style/StatBox">
<TextView android:id="@+id/tvPending" style="@style/StatNumber" />
<TextView style="@style/StatLabel" android:text="@string/stat_pending" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
style="@style/StatCard"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1">
<LinearLayout style="@style/StatBox">
<TextView android:id="@+id/tvDone" style="@style/StatNumber" />
<TextView style="@style/StatLabel" android:text="@string/stat_done" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
<TextView
android:id="@+id/tvPercent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:textAppearance="?attr/textAppearanceTitleMedium" />
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progressDone"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:max="100"
app:trackCornerRadius="6dp"
app:trackThickness="12dp" />
<!-- Tip of the day from https://codingeasily.in/api/android-tips.json -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
app:cardCornerRadius="16dp">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="120dp">
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/tipProgress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:indeterminate="true" />
<LinearLayout
android:id="@+id/tipContent"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:visibility="gone">
<ImageView
android:id="@+id/ivTip"
android:layout_width="match_parent"
android:layout_height="160dp"
android:contentDescription="@string/tip_of_day"
android:scaleType="centerCrop" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/tip_of_day"
android:textAppearance="?attr/textAppearanceLabelLarge"
android:textColor="@color/brand" />
<TextView
android:id="@+id/tvTipTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textAppearance="?attr/textAppearanceTitleLarge" />
<TextView
android:id="@+id/tvTipText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textAppearance="?attr/textAppearanceBodyMedium" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnReadMore"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/read_more" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:id="@+id/tipError"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
android:orientation="vertical"
android:padding="16dp"
android:visibility="gone">
<TextView
android:id="@+id/tvTipError"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnRetry"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/retry" />
</LinearLayout>
</FrameLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
</androidx.core.widget.NestedScrollView>package com.codingeasily.studentdiary.ui.stats
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 androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import com.bumptech.glide.Glide
import com.codingeasily.studentdiary.R
import com.codingeasily.studentdiary.databinding.FragmentStatsBinding
import com.codingeasily.studentdiary.ui.NoteViewModel
import com.codingeasily.studentdiary.ui.TipState
/** Progress numbers from Room + "Tip of the day" from the internet (Retrofit + Glide). */
class StatsFragment : Fragment() {
private var _binding: FragmentStatsBinding? = null
private val binding get() = _binding!!
private val viewModel: NoteViewModel by activityViewModels()
private var total = 0
private var done = 0
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentStatsBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel.countAll.observe(viewLifecycleOwner) { total = it; showProgress() }
viewModel.countDone.observe(viewLifecycleOwner) { done = it; showProgress() }
viewModel.tip.observe(viewLifecycleOwner) { state ->
binding.tipProgress.isVisible = state is TipState.Loading
binding.tipContent.isVisible = state is TipState.Success
binding.tipError.isVisible = state is TipState.Error
when (state) {
is TipState.Success -> {
binding.tvTipTitle.text = state.tip.title
binding.tvTipText.text = state.tip.text
Glide.with(this).load(state.tip.image)
.placeholder(R.drawable.ic_image)
.error(R.drawable.ic_image)
.into(binding.ivTip)
binding.btnReadMore.setOnClickListener {
// Implicit intent: let the phone choose a browser
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(state.tip.link)))
}
}
is TipState.Error -> binding.tvTipError.text = getString(R.string.tip_error, state.message)
TipState.Loading -> Unit
}
}
binding.btnRetry.setOnClickListener { viewModel.loadTip(force = true) }
viewModel.loadTip()
}
private fun showProgress() {
val pending = total - done
val percent = if (total == 0) 0 else done * 100 / total
binding.tvTotal.text = total.toString()
binding.tvDone.text = done.toString()
binding.tvPending.text = pending.toString()
binding.progressDone.setProgressCompat(percent, true)
binding.tvPercent.text = getString(R.string.percent_done, percent)
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}The API behind the tip (server-api/ folder)
By default the app reads the static file api/android-tips.json. The download also contains a full PHP + MySQL API in server-api/ – tips.php, database.sql and a password-protected admin.php to add or hide tips without updating the app. It returns the same JSON, so only TIPS_BASE_URL and TIPS_PATH in network/TipsApi.kt change.
<?php
/* GET tips.php -> {"tips":[ {id,title,text,image,link}, ... ]}
* GET tips.php?id=3 -> {"tips":[ {...one tip...} ]}
* GET tips.php?random=1 -> {"tips":[ {...one random tip...} ]}
* Same JSON shape as android-tips.json, so the app's Tip / TipsResponse classes work unchanged. */
require_once __DIR__ . '/db.php';
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
sd_json(array('error' => 'Only GET is allowed'), 405);
}
try {
$cols = 'id, title, text, image, link';
if (isset($_GET['id'])) {
$id = (int) $_GET['id'];
$st = sd_db()->prepare("SELECT $cols FROM tips WHERE id = ? AND is_active = 1");
$st->execute(array($id));
} elseif (!empty($_GET['random'])) {
$st = sd_db()->query("SELECT $cols FROM tips WHERE is_active = 1 ORDER BY RAND() LIMIT 1");
} else {
$st = sd_db()->query("SELECT $cols FROM tips WHERE is_active = 1 ORDER BY id");
}
$rows = $st->fetchAll();
foreach ($rows as $i => $r) {
$rows[$i]['id'] = (int) $r['id']; // keep id numeric for Gson
}
if (isset($_GET['id']) && !$rows) {
sd_json(array('error' => 'Tip not found', 'tips' => array()), 404);
}
sd_json(array('tips' => $rows));
} catch (Exception $e) {
sd_json(array('error' => 'Database error – check config.php and import database.sql', 'tips' => array()), 500);
}Step 10: Settings and Help
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/profile"
android:textAppearance="?attr/textAppearanceTitleMedium" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilName"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="@string/your_name"
app:startIconDrawable="@drawable/ic_person">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPersonName|textCapWords" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnSaveName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/save" />
<com.google.android.material.divider.MaterialDivider
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginVertical="16dp" />
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switchDark"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="56dp"
android:text="@string/dark_mode" />
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switchReminders"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="56dp"
android:text="@string/reminder_notifications" />
<com.google.android.material.divider.MaterialDivider
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginVertical="16dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnClearDone"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/clear_done"
app:icon="@drawable/ic_delete" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:gravity="center"
android:text="@string/version_text"
android:textAppearance="?attr/textAppearanceBodySmall" />
</LinearLayout>
</ScrollView>package com.codingeasily.studentdiary.ui.settings
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import com.codingeasily.studentdiary.R
import com.codingeasily.studentdiary.data.SettingsStore
import com.codingeasily.studentdiary.databinding.FragmentSettingsBinding
import com.codingeasily.studentdiary.ui.NoteViewModel
import com.google.android.material.dialog.MaterialAlertDialogBuilder
/** Name, dark mode and reminder switch saved in SharedPreferences. */
class SettingsFragment : Fragment() {
private var _binding: FragmentSettingsBinding? = null
private val binding get() = _binding!!
private val viewModel: NoteViewModel by activityViewModels()
private lateinit var settings: SettingsStore
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentSettingsBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
settings = SettingsStore(requireContext())
binding.etName.setText(settings.userName)
binding.switchDark.isChecked = settings.darkMode
binding.switchReminders.isChecked = settings.remindersEnabled
binding.btnSaveName.setOnClickListener {
val name = binding.etName.text?.toString()?.trim().orEmpty()
if (name.isEmpty()) {
binding.tilName.error = getString(R.string.error_name)
} else {
binding.tilName.error = null
settings.userName = name
Toast.makeText(requireContext(), R.string.saved, Toast.LENGTH_SHORT).show()
}
}
binding.switchDark.setOnCheckedChangeListener { _, checked ->
settings.darkMode = checked
settings.applyTheme() // the activity is recreated with the new theme
}
binding.switchReminders.setOnCheckedChangeListener { _, checked ->
settings.remindersEnabled = checked
}
binding.btnClearDone.setOnClickListener {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.clear_done)
.setMessage(R.string.clear_done_confirm)
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.delete) { _, _ ->
viewModel.deleteDone { count ->
if (isAdded) Toast.makeText(
requireContext(),
resources.getQuantityString(R.plurals.deleted_count, count, count),
Toast.LENGTH_SHORT
).show()
}
}
.show()
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}The Help screen (HelpFragment.kt + fragment_help.xml) is in the WebView lesson.
Step 11: Strings, themes and icons
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Student Diary</string>
<string name="tagline">Notes · Homework · Reminders</string>
<string name="hello_user">Hello, %1$s 👋</string>
<!-- menus -->
<string name="menu_notes">Notes</string>
<string name="menu_subjects">Subjects</string>
<string name="menu_stats">Progress</string>
<string name="menu_favourites">Favourites</string>
<string name="menu_settings">Settings</string>
<string name="menu_help">Help & Tutorial</string>
<string name="menu_more">More</string>
<string name="share_app">Share app</string>
<string name="share_text">I organise my homework with Student Diary – built in the free Android course at https://codingeasily.in/tutorial/android/</string>
<string name="search">Search</string>
<string name="search_hint">Search notes…</string>
<!-- tabs -->
<string name="tab_all">All</string>
<string name="tab_pending">Pending</string>
<string name="tab_done">Done</string>
<!-- list -->
<string name="add_note">Add note</string>
<string name="empty_notes">No notes here yet.\nTap + to add one.</string>
<string name="empty_subjects">Add a note to see your subjects.</string>
<string name="note_deleted">\"%1$s\" deleted</string>
<string name="undo">Undo</string>
<string name="due_at">Due %1$s</string>
<string name="mark_done">Mark as done</string>
<string name="favourite">Favourite</string>
<string name="photo">Photo</string>
<plurals name="notes_count">
<item quantity="one">%d note</item>
<item quantity="other">%d notes</item>
</plurals>
<!-- editor -->
<string name="note">Note</string>
<string name="hint_title">Title</string>
<string name="hint_content">Details</string>
<string name="hint_subject">Subject</string>
<string name="priority">Priority</string>
<string name="priority_low">Low</string>
<string name="priority_medium">Medium</string>
<string name="priority_high">High</string>
<string name="add_to_favourites">Add to favourites</string>
<string name="reminder">Remind me</string>
<string name="pick_date">Pick date & time</string>
<string name="pick_time">Pick time</string>
<string name="add_photo">Add photo</string>
<string name="remove_photo">Remove</string>
<string name="save_note">Save note</string>
<string name="saved">Saved</string>
<string name="error_title">Please enter a title</string>
<string name="error_due">Choose a reminder time in the future</string>
<string name="image_error">Could not read that image</string>
<string name="delete">Delete</string>
<string name="cancel">Cancel</string>
<string name="delete_note">Delete note?</string>
<string name="delete_confirm">\"%1$s\" will be deleted permanently.</string>
<!-- stats -->
<string name="stat_total">Total</string>
<string name="stat_pending">Pending</string>
<string name="stat_done">Done</string>
<string name="percent_done">%1$d%% completed</string>
<string name="tip_of_day">Tip of the day</string>
<string name="read_more">Read more</string>
<string name="retry">Retry</string>
<string name="tip_error">Could not load the tip (%1$s). Check your internet.</string>
<!-- settings -->
<string name="profile">Profile</string>
<string name="your_name">Your name</string>
<string name="save">Save</string>
<string name="error_name">Name cannot be empty</string>
<string name="dark_mode">Dark mode</string>
<string name="reminder_notifications">Reminder notifications</string>
<string name="clear_done">Delete all done notes</string>
<string name="clear_done_confirm">All notes marked as done will be deleted.</string>
<plurals name="deleted_count">
<item quantity="one">%d note deleted</item>
<item quantity="other">%d notes deleted</item>
</plurals>
<string name="version_text">Student Diary 1.0 · made in the CodingEasily Android course</string>
<!-- notifications -->
<string name="channel_reminders">Reminders</string>
<string name="channel_reminders_desc">Homework and exam reminders</string>
<string name="reminder_title">⏰ %1$s</string>
<string name="notifications_off">Reminders will not appear until you allow notifications in Settings.</string>
</resources><?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="subjects">
<item>General</item>
<item>Maths</item>
<item>Physics</item>
<item>Chemistry</item>
<item>Biology</item>
<item>English</item>
<item>Hindi</item>
<item>Computer</item>
<item>History</item>
</string-array>
</resources><?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_search"
android:icon="@drawable/ic_search"
android:title="@string/search"
app:actionViewClass="androidx.appcompat.widget.SearchView"
app:showAsAction="ifRoom|collapseActionView" />
</menu><?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_delete"
android:icon="@drawable/ic_delete"
android:title="@string/delete"
app:showAsAction="ifRoom" />
</menu><?xml version="1.0" encoding="utf-8"?>
<!-- 108x108 adaptive icon foreground: a white notebook with a green check -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:fillColor="#FFFFFF" android:pathData="M38,30h32a4,4 0,0 1,4 4v40a4,4 0,0 1,-4 4h-32a4,4 0,0 1,-4 -4v-40a4,4 0,0 1,4 -4z" />
<path android:fillColor="#BBF7D0" android:pathData="M34,34h6v40h-6z" />
<path android:fillColor="#16A34A" android:pathData="M47,55l5,5 11,-12 3,3 -14,15 -8,-8z" />
</vector>Themes (values/themes.xml, values-night/themes.xml) and colours are in the Material Theme lesson; all 20 vector icons are in the download ZIP.
Step 12: Test checklist
- 1Add 3 notes with different subjects and priorities → they appear in All and Pending
- 2Tick one as done → it moves to the Done tab and the Progress % changes
- 3Swipe a note → Snackbar → Undo brings it back
- 4Search in the toolbar filters every tab
- 5Set a reminder 2 minutes ahead, close the app → notification arrives and opens the app
- 6Attach a photo, restart the phone → photo still visible
- 7Turn on Dark mode → the whole app switches theme
- 8Rotate on every screen → nothing is lost
- 9Turn off internet → Progress shows the tip error with Retry
Make it your own
- Add a timetable screen (Room table + weekly RecyclerView).
- Backup notes to your own PHP API with Retrofit (you will build such an API in the e-commerce course).
- Home-screen widget showing today's pending notes.
- Translate to Hindi with
values-hi/strings.xml.
Your app is complete. In the next lesson you will create a signed release bundle and publish it on Google Play.
