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
- Using Hilt for dependency injection
- ViewModel read compose navigation arguments via SavedStateHandle
@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}