Jetpack Compose Data Entry Screen With Firestore (Sample)

Screen

  • Prompt save changes if data changed
  • Handle Delete
  • Handle Edit or Create New
@OptIn(ExperimentalMaterialApi::class)@Composablefun 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()        }    }}
@Composablefun 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

@Composablefun 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()        }    }}@Composablefun 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

@HiltViewModelclass 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.ktdata 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}

❤️ Is this article helpful?

Buy me a coffee ☕ or support my work via PayPal to keep this space 🖖 and ad-free.

Do send some 💖 to @d_luaz or share this article.

✨ By Desmond Lua

A dream boy who enjoys making apps, travelling and making youtube videos. Follow me on @d_luaz

👶 Apps I built

Travelopy - discover travel places in Malaysia, Singapore, Taiwan, Japan.