Jetpack Compose Data Entry Screen With Firestore (Sample)

May 6, 2022

Screen

  • Prompt save changes if data changed
  • Handle Delete
  • Handle Edit or Create New
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun AlbumScreen(
    viewModel: AlbumViewModel = viewModel(),
    albumId: String? = null,
    onBack: (data: Map<String, Any>?) -> Unit,
) {
    
    val scaffoldState = rememberScaffoldState()
    val coroutineScope = rememberCoroutineScope()
    val focusManager = LocalFocusManager.current

    var showBusy by remember { mutableStateOf(false) }
    var showDeleteConfirm by remember { mutableStateOf(false) }
    var showExitSaveConfirm by remember { mutableStateOf(false) }

    val album = viewModel.album

    var titleError by rememberSaveable { mutableStateOf<String?>(null) }

    fun onSave(back: Boolean) {
        val tasks = mutableListOf<Deferred<Boolean>>()

        if (viewModel.isAlbumDiff) {
            focusManager.clearFocus()

            titleError = if (album.title.isNullOrEmpty()) {
                "Title is required"
            }
            else {
                null
            }

            if (titleError == null) {
                val task = coroutineScope.async {

                    // showBusy = true

                    val data = mutableMapOf<String, Any?>(
                        Album.TITLE to album.title,
                    )

                    val result = viewModel.saveAlbum(data).single()

                    when (result.status) {
                        Status.SUCCESS -> {
                            /*
                            scaffoldState.snackbarHostState.showSnackbar("Saved")
                            onBack()
                             */
                            true
                        }
                        Status.ERROR -> {
                            scaffoldState.snackbarHostState.showSnackbar("Error: ${result.message}")
                            false
                        }
                        else -> false
                    }

                    // showBusy = false
                }
                tasks.add(task)
            }
        }

        coroutineScope.launch {
            showBusy = true
            val results = tasks.awaitAll()
            showBusy = false

            for (result in results) {
                if (!result) return@launch
            }

            scaffoldState.snackbarHostState.showSnackbar("Saved")

            if (back)
                onBack(viewModel.backData)
        }
    }

    fun onSaveBack() {
        if (viewModel.isAlbumDiff)
            showExitSaveConfirm = true
        else
            onBack(viewModel.backData)
    }

    fun onDelete() {
        coroutineScope.launch {
            showBusy = true
            viewModel.deleteAlbum()
            scaffoldState.snackbarHostState.showSnackbar("Deleted")
            showBusy = false
            onBack(viewModel.backData)
        }
    }

    BackHandler {
        onSaveBack()
    }

    Scaffold(
        scaffoldState = scaffoldState,
        topBar = {
            TopAppBar(
                title = {},
                navigationIcon = {
                    IconButton(onClick = { onSaveBack() }) {
                        Icon(
                            imageVector = Icons.Filled.ArrowBack,
                            contentDescription = "Navigate Up",
                            // tint = MaterialTheme.colors.primary
                        )
                    }
                },
                actions = {
                    IconButton(onClick = { onSave(back = false) }) {
                        Icon(
                            // imageVector = Icons.Filled.Person,
                            painterResource(id = R.drawable.ic_baseline_save_24),
                            contentDescription = "Save"
                        )
                    }

                    var showMenu by remember { mutableStateOf(false) }

                    IconButton(onClick = { showMenu = !showMenu }) {
                        Icon(
                            imageVector = Icons.Default.MoreVert,
                            contentDescription = "More",
                        )
                    }
                    DropdownMenu(
                        modifier = Modifier.width(200.dp),
                        expanded = showMenu,
                        onDismissRequest = { showMenu = false }
                    ) {
                        if (!viewModel.album.isNew) {
                            DropdownMenuItem(onClick = { showMenu = false; showDeleteConfirm = true }) {
                                Icon(
                                    imageVector = Icons.Filled.Delete,
                                    contentDescription = "Delete",
                                )

                                Spacer(modifier = Modifier.size(4.dp))

                                Text("Delete", maxLines = 1, overflow = TextOverflow.Ellipsis)
                            }

                        }
                    }
                }
            )
        }
    ) { innerPadding ->
        Box(modifier = Modifier.padding(innerPadding)) {
            AlbumContent(
                album,
                onTitleChange = { viewModel.album = album.copy(title = it) },
                titleError
            )
        }

        if (showDeleteConfirm) {
            ConfirmDialog(
                content = "Confirm Delete?",
                onDismiss = { showDeleteConfirm = false },
                onConfirm = {
                    showDeleteConfirm = false
                    onDelete()
                }
            )
        }

        if (showExitSaveConfirm) {
            ConfirmDialog(
                content = "Save changes?",
                onDismiss = { onBack(viewModel.backData) },
                onConfirm = {
                    showExitSaveConfirm = false
                    onSave(back = true)
                }
            )
        }

        if (showBusy) {
            BusyDialog()
        }
    }
}
@Composable
fun AlbumContent(
    album: Album,
    onTitleChange: (String) -> Unit,
    titleError: String? = null,
    val title = album.title ?: ""
    val context = LocalContext.current
    val modifier = Modifier.fillMaxWidth()

    Column {
        LazyColumn(modifier = Modifier.padding(4.dp)) {
            item {
                OutlinedTextField(
                    modifier = modifier,
                    value = title,
                    onValueChange = { onTitleChange(it) },
                    label = { Text("Title") },
                    singleLine = true,
                    isError = titleError != null,
                    keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Words)
                )   
            }

            item {
                Spacer(modifier = Modifier.padding(4.dp))
            }
        }
    }
}

Helper Composable

@Composable
fun BusyDialog(title: String? = null) {
    Dialog(
        onDismissRequest = { },
        DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false)
        ) {
        Box(
            contentAlignment = Alignment.Center,
            modifier = Modifier
                .size(100.dp)
                .background(
                    MaterialTheme.colors.surface.copy(alpha = 0.7f),
                    shape = RoundedCornerShape(8.dp)
                )
        ) {
            CircularProgressIndicator()
        }
    }
}

@Composable
fun ConfirmDialog(title: String? = null, content: String, onDismiss: () -> Unit, onConfirm: () -> Unit) {
    AlertDialog(
        modifier = Modifier.fillMaxWidth(),
        onDismissRequest = {
            onDismiss()
        },
        text = {
            Column (
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(8.dp),
            ) {
                if (!title.isNullOrEmpty()) {
                    Text(title,
                        modifier = Modifier.padding(vertical = 8.dp),
                        style = MaterialTheme.typography.subtitle1)
                }

                Text(content)
            }
        },
        // buttons = { }
        dismissButton = {
            Button(onClick = { onDismiss() }) {
                Text("No")
            }
        },
        confirmButton = {
            Button(onClick = { onConfirm() }) {
                Text("Yes")
            }
        }
    )
}

ViewModel

@HiltViewModel
class AlbumViewModel @Inject constructor(
    // @ApplicationContext applicationContext: Context,
    private val db: FirebaseFirestore,
    private val auth: FirebaseAuth,
    savedStateHandle: SavedStateHandle
): ViewModel() {

    private var originalAlbum: Album? = null
    var album by mutableStateOf(Album())

    val isAlbumDiff: Boolean
        get() = originalAlbum != album

    private val albumId = savedStateHandle.get<String>("albumId")

    val currentUser: FirebaseUser?
        get() = auth.currentUser

    val backData = mutableMapOf<String, Any>()

    private val albumFlow = flow {
        val collection = auth.currentUser?.let { currentUser ->
            albumId?.let {
                db.collection(User.COLLECTION_NAME).document(currentUser.uid)
                    .collection(Album.COLLECTION_NAME).document(albumId)
            }
        }
        if (collection != null) {
            val doc = collection.get().await()
            val item = Album.toObject(doc)!!

            emit(item)
        }
        else {
            emit(Album())
        }
    }

    init {
        viewModelScope.launch {
            albumFlow.collect {
                album = it
                originalAlbum = album.copy()
            }
        }
    }

    fun saveAlbum(data: MutableMap<String, Any?>) = flow {
        Timber.d("saveAlbum=$data")
        val item = album

        currentUser?.uid?.let { uid ->
            // don't use viewModelScope, else it might be cancelled if click UI too fast
            // GlobalScope.launch(Dispatchers.Default) {
            val batch = db.batch()

            val isTitleChanged = originalAlbum?.title != data[Album.TITLE]
            item.save(db, uid, data, isTitleChanged = isTitleChanged, batch = batch)

            try {
                batch.commit()

                // optional: pass data onBack
                if (originalAlbum.isNew) {
                  backData["newId"] = album.id
                }

                originalAlbum = album.copy()


            }
            catch (e: Exception) {
                emit(Resource.error(e, data = null))
            }
            finally {
                emit(Resource.success(data = null))
            }
        }
    }

    fun deleteAlbum() {
        auth.currentUser?.uid?.let { uid ->
            album.delete(db, uid)
        }
    }
}

Model

data class User {
    companion object {
        const val COLLECTION_NAME = "user"

        fun colRef(firestore: FirebaseFirestore) = firestore.collection(COLLECTION_NAME)
    }
}

data class Album(
    var id: String? = null,
    var created: Timestamp? = null,
    var modified: Timestamp? = null,
    var title: String? = null
) {
    companion object {
        const val COLLECTION_NAME = "album"

        const val CREATED = "created"
        const val MODIFIED = "modified"
        const val TITLE = "title"

        fun colRef(firestore: FirebaseFirestore, uid: String) = User.colRef(firestore).document(uid).collection(
            COLLECTION_NAME
        )

        fun toObject(doc: DocumentSnapshot): Album? {
            val item = doc.toObject<Album>()
            item?.id = doc.id
            return item
        }
    }

    val isNew: Boolean
        get() = id == null

    fun save(
        db: FirebaseFirestore,
        uid: String,
        data: MutableMap<String, Any?>,
        isTitleChanged: Boolean,
        batch: WriteBatch? = null
    ) {

        val userRef = User.colRef(db).document(uid)
        val ref = id?.let { userRef.collection(COLLECTION_NAME).document(it) } ?: userRef.collection(
            COLLECTION_NAME
        ).document()

        val isCommit = batch == null
        @Suppress("NAME_SHADOWING")
        val batch = batch ?: db.batch()

        if (isNew && !data.containsKey(CREATED)) {
            created = Timestamp.now()
            data[CREATED] = created
        }

        if (!data.containsKey(MODIFIED)) {
            modified = Timestamp.now()
            data[MODIFIED] = modified
        }


        if (isTitleChanged && !isNew && TITLE in data) {
            // I needed to update something if title changed
        }

        if (isNew) {
            batch.set(ref, data)
            id = ref.id
        }
        else {
            batch.update(ref, data)
        }

        if (isCommit) batch.commit()
    }

    fun delete(db: FirebaseFirestore, uid: String) {
        val id = checkNotNull(id) { "Missing Album.id" }

        val batch = db.batch()

        val ref = colRef(db, uid).document(id)
        batch.delete(ref)

        // perform some other update if necessary

        batch.commit()
    }
}

Helper

enum class Status {
    SUCCESS,
    ERROR,
    LOADING
}

// https://github.com/android/architecture-components-samples/blob/master/GithubBrowserSample/app/src/main/java/com/android/example/github/vo/Resource.kt
data class Resource<out T>(val status: Status, val data: T?, val message: String?) {
    companion object {
        fun <T> success(data: T?, message: String? = null): Resource<T> {
            return Resource(Status.SUCCESS, data, message)
        }

        fun <T> error(message: String, data: T? = null): Resource<T> {
            return Resource(Status.ERROR, data, message)
        }
        /*
        fun <T> error(e: Exception, data: T? = null): Resource<T> {
            return Resource(Status.ERROR, data, e.toString())
        }

         */

        fun <T> error(e: java.lang.Exception, data: T? = null): Resource<T> {
            return Resource(Status.ERROR, data, e.toString())
        }

        fun <T> loading(data: T?, message: String? = null): Resource<T> {
            return Resource(Status.LOADING, data, message)
        }
    }

    val isSuccess = status == Status.SUCCESS
}
This work is licensed under a
Creative Commons Attribution-NonCommercial 4.0 International License.