Skip to content

About

An android application with Google auth which implements network calls using retrofit and Moshi showing user data from jsonplaceholder , A task list to add task & details and apply CRUD using firebase Firestore, A unit converter, user profile with Data Persistent using Room with Update, Screen which import & plays music

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Basic UI Profile Card

  1. Create a basic outlined UI card
    1. Added elevation to show shadow
    2. Divider to partition
    3. Added image and circle shape to iconize
    4. Text fields to take input from the user (Name, About me) with placeholder

Improvements ->

  1. Added image picker to pick an image from the gallery and save it in a state turning URI → String

    1. If the imageUriString is filled, then we are showing it using AsyncImage to make sure that while loading the image, it doesn’t freeze the UI as the string needs to be converted back to URI.
    2. Else we pick from the gallery using imagePickerLauncher with image/* to only pick images.
  2. Added a button to trigger the ViewProfileScreen navigation.

    1. We give a callback function onSaveProfile to the CardMinimalExample to fill it with data which is of type ProfileData defined by serializable ProfileData. This makes sure we don’t make any errors while storing data types or any others (if made any, then we can detect it at compile time rather than runtime).
    2. Inside the navHost, while mapping out all the possible routes inside CardMinimalExample, we define what we do with that ProfileData.
    3. We convert it to JSON, then encode it using UTF-8 to make sure that any special symbols don’t cause any unexpected behavior.
    4. Then using navController, we navigate to the intended route + the JSON data.
    5. Under all the possible definitions of the route, we define a composable that sits between the navigation call and the ViewProfileDetail screen to accept the JSON data sent to that particular route and connects the screen by giving arguments.
    6. Finally, the received data is shown in the detail screen.
  3. Reusable format for defining navigation inside any of the original Profile, Unit Conversion, TaskList. In this fashion, we can define routes that are inside another screen rather than easily accessible.

    1. We are also not using navigation inside any screen, which would cause tight coupling, and keeping all the navigation concerns inside one function keeps things tidy and easily understandable.
AsyncImage(
    model = Uri.parse(imageUriString), // Convert String back to Uri for Coil
    contentDescription = "Profile Image",
    modifier = Modifier
        .size(80.dp)
        .clip(CircleShape)
        .clickable { imagePickerLauncher.launch("image/*") }
        .border(1.dp, Color.Black, CircleShape),
    contentScale = ContentScale.Crop
)

// to not cause UI freeze while rendering image

‌

WARNING ->

Jetpack Compose Navigation works best with primitive data types (like String, Int, Boolean) passed as route arguments. But when you need to pass more complex objects (e.g., a User or Product class), you must serialize that object into a String format (typically JSON or URI-encoded strings).

Latest Improvement and Reflection ->

‌

While Compose Navigation is best suited for passing primitive type data like string, int, boolean… sometimes we might be sure that the object that we want to pass is simple enough to be contained within the bundle size of the destination route. In that case, we can create a Custom NavType where we can manually serialize and deserialize them.


Passing Data Between Screens

In Jetpack Compose Navigation, only primitive types like String, Int, and Boolean can be directly passed as route arguments. When you need to pass complex data classes like User or Product, you must first serialize them into a string format — typically using JSON. This is because the navigation system does not support complex objects as arguments directly and expects route parameters to be simple strings.


  1. Serialization & Deserialization

Serialization is the process of converting an object into a String (usually JSON) so it can be passed through the navigation route. Deserialization is the reverse — taking that string and converting it back into the original object in the destination screen. Common libraries used for this process are Gson, Moshi, or kotlinx.serialization. This approach allows you to move complex data between composables, but it comes with limitations.


  1. Custom NavType

Jetpack Compose allows you to create a custom NavType to support passing complex objects. While this seems like a cleaner abstraction, it still relies on the object being converted to a string internally and stored in a Bundle. That means it’s subject to the same size and encoding limitations as manual serialization. Overusing this method for large objects can lead to navigation errors or app crashes.


  1. Recommended Best Practice

The best practice is to avoid passing entire objects through navigation routes. Instead, pass a unique identifier like a userId, productId, or orderNumber. On the destination screen, use this ID to fetch the full object from a ViewModel, a repository, or a local database like Room. This keeps route arguments lightweight and ensures better performance and maintainability.


  1. Why This is Better

Using IDs instead of full objects for navigation is more scalable, reliable, and performant. It avoids problems related to route size limits, JSON encoding/decoding issues, and long strings in the back stack. It also makes your navigation logic easier to maintain, test, and refactor — especially as your app grows in complexity.


Add Bottom Navigation Bar

  1. Added bottom navigation bar from Android Doc
    1. Created navigation for Profile, Unit Converter, Task List
    2. Defined all the destinations with route, label, icon, and content description
    3. NavHost for mapping route → Screen
    4. Finally, in the screen, the user clicks on the icons, and the route is passed down to transfer to the new screen

Improvements ->

  1. Bottom navigation bar is refactored to type safe navigation
  2. The problem is we can’t pass argument in navigation route as a complex type without defining its serialization and deserialization manually

Add Simple Unit Converter

  1. Defined remember state to take and store input from the user.
  2. Added two clickable texts “From” and “To” to show a dropdown menu of units.
  3. Input from the user and selected “From” and “To” is the action trigger and shows the result in the input field.
  4. If the same unit is selected, then it will bounce back the menu item.
  5. Further improvement is underway.
    1. Added the result text down the “To” clickable, which prompts the user to give input and units.
    2. Units given in any order (to → from) / (from → to) will show the result for the given input.
    3. User can change any units (from/to) to see different conversions.
    4. Same type units are not allowed.
    5. If the user first gives input, then gives units in any order, it will show the result.
    6. Invalid input is not allowed.
    7. Result shows in the following format “x miles is y kilometers.”
  6. Refactoring it to MVVM ->
    1. We put all the states in a data class.
    2. We put all the actions in a sealed interface.
    3. We implement them in the ViewModel class.
      1. We make the states private but mutable in the ViewModel class but public and immutable for composable fun; that way, our data remains safe.
      2. e.g. onValueChange = { onEvent(ConverterEvent.InputChanged(it)) }, previously, we did the state change in place.
      3. We implement each of the events that can happen inside the ViewModel class and change the state value as it's mutable inside ViewModel and update the states data class using copy, and as we have made the ui_states observable by the Screen, we see the update in composable.

Screen Recomposition

When you are on the same screen (you haven't navigated away and back) and you update a state variable:

1.The Specific State that Changed: This state now holds its new value.

2.Recomposition Triggered: Compose schedules a recomposition for the current composable (and any other composables that read the changed state).

3.Other remembered States in the Same Composable:

•These are NOT reset to their default values. The purpose of remember is precisely to preserve their current values across recompositions within the same continuous composition of that screen.

•If remember reset all other states every time one state changed, it would be impossible to build any interactive UI, as every input would wipe out everything else.

4.UI Updates: The composable function re-executes.

•It reads the current values of all its remembered states (the changed one and the unchanged ones).•It emits new UI elements based on these current values.

•Compose then diffs this new UI with the old UI and updates only what's necessary on the screen.5.No Change to Other Independent States: States in other independent composable functions that were not affected by this specific state change will not be affected or reset. Recomposition is generally scoped.


TaskList Screen With CRUD

  1. Input field to take input from the user and a button to add.
  2. Taken one mutableListOf to add new input from the user.
  3. Adding new input to the front of the list with index 0.
  4. Deleting a task from the list using index.

Improvements ->

  1. Instead of Column, we can use LazyColumn, which is efficient in terms of displaying the content inside (i.e., rather than displaying all at once, it only displays the amount that the screen can hold, and when we scroll, it dynamically shows the other items on the go).
  2. LC also doesn’t need to define verticalScrolling as it handles itself.
  3. LC doesn’t take Composable items directly, as opposed to Column, which renders whatever we put inside.
    1. Instead, we can define them using item, item(count), (item() {}, itemIndexed() {} for a list of items).

Further Improvements ->

  1. Make the full List CRUD using Firestore with Details View, and Update and Create, Delete, and Read are also done using the MVVM Design Pattern.
// this is for list of items

LazyColumn(// define style) {
    itemsIndexed( 
        items = taskList, 
        itemContent = { index, task -> 
            TaskItem(
                taskText = task,
                onDelete = {
                     taskList.removeAt(index) 
                }
            )
        }
    )
}

// single usage of item **CANNOT** directly use composable fun

 LazyColumn {
    item {
        Text("This is a Header", style = MaterialTheme.typography.headlineSmall)
    }

    // ... other items from a list ...

    item {
        Divider()
    }

    item {
        Text("This is a Footer", style = MaterialTheme.typography.bodySmall)
    }
}


// defining item number before is also a possible way to render

LazyColumn {
    items(
        count = 10,
        key = { index -> "item_$index" } // Example key
    ) { index ->
        Text("Item Number: $index", modifier = Modifier.padding(16.dp))
    }
}


Authentication using google account

  1. Google ID credentials are received upon request from the user from the selected Gmail account on the screen.
  2. Using that ID to create authentication in Firebase depending on whether the user is new or old.
  3. Upon successfully signing in, the UID is stored by the Firebase SDK locally, and the state is saved in SharedPreferences as a key-value pair so that the user doesn’t have to repeatedly sign in.
  4. App content is shown based on the ViewModel state of the authentication.
    1. If true, then the Main Screen.
    2. Else, the Authentication Screen.
    3. Logout button to sign out.

Adding local cache with Room alongside Firebase to the TaskList screen

  • Following the addition of the Repository to implement SoC to make the ViewModel only focus on providing the data to the Screen and making it more testable, and making the repository handle all the implementation.
  1. The addition of the repository made the addition of Room more convenient, as the ViewModel won’t have to know about the details and only focus on providing data.

  2. To add Room, we need 3 things:

    1. Entity (with column names)
      1. We define each entity with a name, and inside it, there are the attributes that are necessary.
    2. Dao for doing operations on the Table
      1. All the operations we need to do from the repository are defined here, and it's customizable, making it more robust for offline support.
    3. Database to create the database and initialize and get it (Singleton) and also implement the Daos.
      1. This is the place where we combine and let any screen create/get the database.
      2. First, we list all the entities that are tied to the database to have access to them from one point.
      3. Then we include the Daos, which are abstract, and the Room Database takes care of them by implementing them.
      4. Inside, we create a variable INSTANCE which holds the database, and if null, then we build one, put it here, and return the database. This is implemented by the Singleton DP to make sure there is only one instance of the database; otherwise, the data will be lost.
  3. We give parameters to the constructor of the repository of Daos to call them when needed, alongside Firebase operations.

  4. In the ViewModel, we give a parameter of type repository to have access to them inside, but creating the ViewModel from the Screen is not the best practice, as for any configuration change, we will need to create the ViewModel again, leading to the loss of data and fetching again.

    1. Rather, we use ViewModelProvider.Factory to make sure it handles that creation by overriding the create method, which we wouldn’t need to do if the constructor was parameterless. Now we define how it should create the ViewModel to avoid data loss.
  5. And finally, from the screen, we call the ViewModel and do the rest of the work.

This is just for the implementation of Room for Local Cache. The proper way to do it is to make it more robust with a proper sync mechanism, which I didn’t do here. When making Room SSoT for the application, we need more robust functions that will handle what would happen when offline and user input or delete multiple items, when to sync the local and cloud, and what would be the error recovery and error handling.


// Entity 
@Entity(tableName = "tasks")
data class TaskListModel(
    @PrimaryKey
    var id: String = "",          // Firestore document ID (manually set after fetching)
    val name: String = "",        // Task name (required)
    val details: String? = null,   // Optional task details

    @ServerTimestamp
    val createdAt: Timestamp? = Timestamp.now(),   // Creation timestamp (formatted as a string)
    var userId: String = "",  // User ID for task ownership
    )

// Dao
@Dao
interface TaskDao {
    @Query("SELECT * FROM tasks WHERE userId = :userId ORDER BY createdAt ASC")
    fun getTasksForUser(userId: String): Flow<List<TaskListModel>> // Observe changes from Room
    

    @Insert(onConflict = OnConflictStrategy.REPLACE) // Replace if task with same ID exists
    suspend fun insertTask(task: TaskListModel)

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertAllTasks(tasks: List<TaskListModel>)
    

    @Query("DELETE FROM tasks WHERE id = :taskId AND userId = :userId")
    suspend fun deleteTaskById(taskId: String, userId: String)
    
}

// Database 

@Database(entities = [TaskListModel::class], version = 1, exportSchema = false)
@TypeConverters(Converters::class) // Add this if you create Converters.kt
abstract class AppDatabase : RoomDatabase() {
    abstract fun taskDao(): TaskDao

    // singleton instance of the database to make sure we don't loose data
    companion object {
        @Volatile
        private var INSTANCE: AppDatabase? = null

        fun getDatabase(context: Context): AppDatabase {
            return INSTANCE ?: synchronized(this) {
                val instance = Room.databaseBuilder(
                    context.applicationContext,
                    AppDatabase::class.java,
                    "task_database"
                ).build()

                INSTANCE = instance
                instance
            }
        }
    }
}





Added Room for UserInfo

  1. Added local cache for UserInfo storage rather than fetching from Firebase.

  2. If it exists in the room with that UID, then fetch; if not, then fetch from Firebase and store it in the room.

  3. Show information from the room in UserProfile.

  4. Update information locally in the Room table (photo, name, about me).

  5. Caching images to load instantaneously.

    1. Pick an image from storage using the launcher and call the function to store it in the storage to make it accessible later, as file/* is accessible but content/* is not (requires permission which it loses after closing the app).
  6. Needed to add migration as I need to create a new table and change the version.


@Database(entities = [TaskListModel::class, UserInfo::class], version = 2, exportSchema = false)

val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(db: SupportSQLiteDatabase) {
        db.execSQL("CREATE TABLE IF NOT EXISTS `profile_info` (`userId` TEXT NOT NULL, `name` TEXT NOT NULL, `aboutMe` TEXT NOT NULL, `profilePictureUrl` TEXT, PRIMARY KEY(`userId`))")
    }
}


Adding HTTP call to fetch data from API using Retrofit and Moshi

  1. We need a data class for storing the response from the API calls.

  2. We set up the Retrofit interface where we define what kind of HTTP request we want to do.

    1. Mainly, we did a GET request with a function with a return type of the respective data class.
  3. We need to implement this interface, which Retrofit does.

    1. Inside the Retrofit client object, we define the base URL we want to hit.
    2. We define a logging system that shows us every request and response.
    3. We initialize Moshi for converting from JSON ↔︎ Kotlin object.
  4. Lastly, we initialize the apiService, which is of type Retrofit interface, and we initialize Retrofit, give the base URL, provide the OkHttpClient, add the conversion mechanism through Moshi, and build. Lastly, we instruct to implement the Retrofit Interface.

  5. We then call the API service from the repository to do the request and response call from the screen.


Managing files in Android Studio for better Organization

4 files need to be managed while developing to make sure the necessary libraries are available

  1. settings.gradle.kts

  2. build.gradle.kts ( project level / top level )

  3. build.gradle.kts ( app level / module level )

  4. libs.versions.toml ( version catalog )

  • top level build.gradle.kts is generally not necessary as most of the declaration happens inside module level. If necessary then declare in it ( apply false to not setup it in root dir ) to access from the module level

  • settings.gradle.kts is necessary for including all the module files if there are multiple to give access to them the plugins and dependencies types to let them know where to find the details ( google, mavenCentral,gradlePluginPortal)

step - 1: first add the version in the version section of the .toml file to later access it from the plugins / library section in the .toml file

step - 2: add plugins and dependencies using those versions defined in the .toml file. that way we can update from the .toml file and it will be updated everywhere

step - 3: sync to install them in the project

  • convenient way is to declare dependencies / plugins directly in the module level build.gradle.kts and later using the android studio suggestion include the version in the .toml file and use the alias which android studio automatically manages
  • use comment to group the related dependencies together to make it more organized
  • while committing after the project commit make another which includes the dependencies used for the prev. commit development

There are mainly two types of storage for an application

  1. internal : This storage is private and the user can’t access to it w/o programming. No other app can get access to it and w/ uninstallation it gets removed. Mainly, for sensitive, small, Private information( Room, User info )

  2. external

    1. app-specific external : This means it uses the external storage but its related to the app and when uninstalled gets removed. This can hold bigger data that need not be available to other apps and even users* ( store media files here )
    2. shared external : This is the open storage any app and user can see and access to it

How to copy files in Internal Storage

To copy any file we need to give Context of activity and Uri of that file

  • when i send context and Uri and desired file name to copy to function that copies to ext. storage

  • I first create a input stream for this Uri so that i can send it to files

  • then I create one file using that context inside a dir. ( if dir. not exist then create with a suitable name ) with the parameter name given

  • then I create a output stream for that file to receive the bytes sent form the input stream and copy them in the file

  • lastly i generate the Uri of the file and return it to store in SQLite db. using Room

App specific external storage req. only instead of filesDir → getExternalFilesDir and specifying the type of dir. you want to store the file

we need to put the files in external storage so that room can show it in the app. Using Uri of type content/ requires permission while file/ doesn’t require it.**


Importing media files from storage and saving them in ext. storage of app and generating URI which used to view them

  1. Copying to the app’s external storage is the same as internal storage using getExternalFilesDir instead of filesDir while searching for the dir.

  2. For creating an ExoPlayer

    1. We need to create a player
    2. A view where the player will reside
    3. Then connect them both
    4. Define DisposableEffect for managing the player's lifecycle to later release it when not needed, as well as managing different actions and listeners
    5. Lastly, define the AndroidView and connect the playerView with the AndroidView composable

LaunchedEffect and DisposableEffect

  • both are essential for providing side-effect like listening to event, interacting with the composable with different user cases

  • LE is used when there are suspend function necessary and it supports that( e.g. when opening that screen i need to call a suspend fun. with a key then LE can be used )

  • DE is used when there are no long running or suspend fun but rather needs to listen to composable or subscribing to external source or acquire resource and later needs to disposed to not hold onto resrc. and to prevent leaks

  • if there are release(), close(), unregister() then user DE ( you can use LE inside DE )


About

An android application with Google auth which implements network calls using retrofit and Moshi showing user data from jsonplaceholder , A task list to add task & details and apply CRUD using firebase Firestore, A unit converter, user profile with Data Persistent using Room with Update, Screen which import & plays music

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages