Register a SA Forums Account here!
JOINING THE SA FORUMS WILL REMOVE THIS BIG AD, THE ANNOYING UNDERLINED ADS, AND STUPID INTERSTITIAL ADS!!!

You can: log in, read the tech support FAQ, or request your lost password. This dumb message (and those ads) will appear on every screen until you register! Get rid of this crap by registering your own SA Forums Account and joining roughly 150,000 Goons, for the one-time price of $9.95! We charge money because it costs us money per month for bills, and since we don't believe in showing ads to our users, we try to make the money back through forum registrations.
 
  • Post
  • Reply
LongSack
Jan 17, 2003

Dipping my toes into mobile development, and I'm going with a ToDo app (I know, babby's first mobile app, but I am throwing in some kink by allowing nested items). Starting with Android and Kotlin (which are both new to me), I'm having an issue that I can't seem to make work, and it should be easy.

I'm using a RecyclerView to display the todo items, and I can't for the life of me make the items clickable. Here's the relevant section of MainActivity:
Kotlin code:
        recyclerView = findViewById(R.id.recyclerView)
        adapter = ToDoAdapter(manager.getTopLevelItems(), this)
        val layoutManager: RecyclerView.LayoutManager = LinearLayoutManager(applicationContext)
        recyclerView.layoutManager = layoutManager
        recyclerView.itemAnimator = DefaultItemAnimator()
        recyclerView.addItemDecoration(DividerItemDecoration(this, LinearLayoutManager.VERTICAL))
        recyclerView.adapter = this.adapter
Here's the adapter:
Kotlin code:
class ToDoAdapter(
    private val dataSet: Array<ToDoItem>,
    private val cellClickListener: ICellClickListener
) :
    RecyclerView.Adapter<ToDoAdapter.ViewHolder>() {
    private val format = SimpleDateFormat("yyyy/MM/dd")

    inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view), View.OnClickListener {
        val txtPlus: TextView = view.findViewById(R.id.txtPlus)
        val txtName: TextView = view.findViewById(R.id.txtName)
        val txtDueDate: TextView = view.findViewById(R.id.txtDueDate)

        init {
            view.isClickable = true
            view.isFocusable = true
            view.setOnClickListener(this)
        }

        override fun onClick(p0: View?) {
            val i = adapterPosition
            val item = dataSet[i]
            Log.i("info", "Item ${item.name} clicked")
            cellClickListener.onCellClickListener(item)
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        val view = LayoutInflater.from(parent.context)
            .inflate(R.layout.todo_item, parent, false)
        return ViewHolder(view)
    }
	...
}
I set breakpoints and see that the init is being run, so (in theory) the clickable, focusable, and onClick properties should be set. Yet the onClick method never gets called.

I worked from a Java example that had this as an adapter:
Java code:
public class NoteAdapter extends RecyclerView.Adapter<NoteAdapter.ListItemHolder> {
    private List<Note> mNoteList;
    private MainActivity mMainActivity;

    public NoteAdapter(MainActivity mainActivity, List<Note> noteList){
        mMainActivity = mainActivity;
        mNoteList = noteList;
    }

    @NonNull
    @Override
    public NoteAdapter.ListItemHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
        View itemView = LayoutInflater
                .from(parent.getContext())
                .inflate(R.layout.listitem, parent, false);
        return new ListItemHolder(itemView);
    }

    public class ListItemHolder extends RecyclerView.ViewHolder implements View.OnClickListener {

        TextView mTitle;
        TextView mDescription;
        TextView mStatus;

        public ListItemHolder(View view) {
            super(view);
            mTitle = view.findViewById(R.id.textViewTitle);
            mDescription = view.findViewById(R.id.textViewDescription);
            mStatus = view.findViewById(R.id.textViewStatus);
            view.setClickable(true);
            view.setOnClickListener(this);
        }

        @Override
        public void onClick(View view) {
            mMainActivity.showNote(getAdapterPosition());
        }
    }
}
and this works just fine. What am I doing wrong?

Edit: I just wrote a new Java project with the equivalent of the Kotlin above, and it works as expected.

LongSack fucked around with this message at 19:36 on Jul 19, 2022

Adbot
ADBOT LOVES YOU

LongSack
Jan 17, 2003

Vesi posted:

I switched to jetpack compose recently specifically to get away from recyclerview,, if you're just learning then maybe it's better to stick to the latest paradigms?

Probably. I’ve only just started the UI, so not much effort lost. Still bugs me that that Kotlin code doesn’t work when the Java equivalent works just fine.

LongSack
Jan 17, 2003

FAT32 SHAMER posted:

Try using

val onClick: (dataToSendToHostFragment: Any) -> Unit

Instead of the callback method, then call onClick.invoke(data) from the root of the viewholder

Note: Any is whatever the object you’re returning from the adapter to the fragment is

If I understand you (and this is a huge if), I changed the ViewHolder to this:
Kotlin code:
inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view)/*, View.OnClickListener*/ {
    val txtPlus: TextView = view.findViewById(R.id.txtPlus)
    val txtName: TextView = view.findViewById(R.id.txtName)
    val txtDueDate: TextView = view.findViewById(R.id.txtDueDate)
    init {
        view.isClickable = true
        view.setOnClickListener {
            onClick.invoke(dataSet[adapterPosition])
        }
    }

    val onClick: (data: ToDoItem) -> Unit = {
        Log.i("info", "Item ${it.name} clicked");
    }
}
and it still doesn't work

LongSack
Jan 17, 2003

brand engager posted:

Had to dig up our old app that still uses recyclerviews, we only did view inflating in onCreateViewHolder and we setup any handlers in onBindViewHolder

Except that the Java version works.

It’s moot anyway since I took Vesi’s advice and switched to Compose.

I like it, it reminds me of Blazor components and React functional components.

The only thing I’m a little iffy on is that my MainActivity file is now at the size where if I were programming in C# I’d be looking to refactor some stuff out into separate classes. But since these are functions, I can’t really do that.

LongSack
Jan 17, 2003

smiling giraffe posted:

Yeah you can, it’s kotlin, functions don’t need to be a class

Interesting. I’m new to the language, so this is good info. Thanks.

LongSack
Jan 17, 2003

Question about state ...

I'm storing the name of the most recent ToDoItemList in shared preferences and saving/retrieving the name in onPause and onResume:
Kotlin code:
override fun onResume() {
    super.onResume()
    val prefs = getSharedPreferences(SHARED_PREFERENCES_NAME, MODE_PRIVATE)
    currentListName.value = prefs.getString(CURRENT_LIST_NAME, DEFAULT_LIST_NAME).toString()
    val file = toDoFileRepository.read(currentListName.value!!)
    toDoRepository.loadFile(file)
}

override fun onPause() {
    super.onPause()
    val prefs = getSharedPreferences(SHARED_PREFERENCES_NAME, MODE_PRIVATE)
    val editor = prefs.edit()
    editor.putString(CURRENT_LIST_NAME, currentListName.value)
    editor.commit()
}
Because these functions are not in a Composable, I can't use remember so I'm using MutableLiveState<String> and passing that value down to my root Composable:
Kotlin code:
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContent {
        ToDoPlusTheme {
            val navController = rememberNavController()
            NavHost(navController = navController, startDestination = "root") {
                composable("root") {
                    Root(
                        currentListName,
                        doChoose,
                        navController
                    )
                }
            }
        }
    }
}
@Composable
fun Root(
    currentListName: MutableLiveData<String>,
    chooseNewList: (String) -> Unit,
    navController: NavController
) {
  ...
}
Is there a better way to handle this?

LongSack
Jan 17, 2003

smiling giraffe posted:

You want to hold state in a viewmodel. This is an Android architecture component that is designed to persist as activities move through their lifecycle.

View models typically expose their state using some implementation of the observable pattern, historically this has been livedata, but more recently StateFlow. This allows activities/fragments to observe and react to state when they are ready to do so.

There should be plenty of good guides out there for getting setup with a viewmodel

I was already using view models, but for some reason had the current list name stored in a separate variable. I moved it into the view model, and adjusted my onXxx methods and everything works still. Thanks!

LongSack
Jan 17, 2003

So, while new to Android and Kotlin, I'm trying to do things the "correct" way with dependency injection. I started using Koin but it was a real pain trying to figure out what combinations of import I needed to use where to get inject().

Switched to Hilt and while some things are easier, it's been a huge pain in its own right (I've lost track of the number of adjustments I've had to make to my gradle scripts). But I think I have it finally working, except for one thing.

I want to inject my MainViewModel into my MainActivity. However, MainViewModel has its own dependencies:
Kotlin code:
@HiltViewModel
class MainViewModel @Inject constructor(private val toDoRepository: ToDoRepository) : ViewModel() {

    private val allItems = ArrayList<ToDoItem>()

    // public properties
    var currentFile: MutableState<ToDoFile?> = mutableStateOf(null)
    var topItem: MutableState<ToDoItem?> = mutableStateOf(null)
    var items: SnapshotStateList<ToDoItem> = mutableStateListOf()

    fun isValid() = currentFile.value != null

		...
}
I tried
Kotlin code:
@AndroidEntryPoint
class MainActivity : ComponentActivity() {

    @Inject
    lateinit var mainViewModel: MainViewModel

       ...
}
but get this error message: "Injection of an @HiltViewModel class is prohibited since it does not create a ViewModel instance correctly. Access the ViewModel via the Android APIs (e.g. ViewModelProvider) instead."

Fine, I can do that. Let's try this:
Kotlin code:
@AndroidEntryPoint
class MainActivity : ComponentActivity() {

    lateinit var mainViewModel: MainViewModel

		...

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        mainViewModel = ViewModelProvider
            .AndroidViewModelFactory
            .getInstance(application)
            .create(MainViewModel::class.java)
    }

	...
But now I get an exception that at its root has this message: "Caused by: java.lang.InstantiationException: java.lang.Class<solutions.vjk.todoplus.viewmodels.MainViewModel> has no zero argument constructor"

Well, yeah, that's kinda the point.

What am I doing wrong? If I have to instantiate the MainViewModel in my MainActivity, I'll also have to instantiate all of its dependencies and all of their dependencies, at which point it stops making sense to use DI.

LongSack
Jan 17, 2003

Volmarias posted:

You still use "by viewmodels()" when using hilt.

Thank you, that did the trick.

LongSack
Jan 17, 2003

OK Next problem.

When using state, how does compose determine that the state has in fact changed and that a recomposition is necessary? Does it use the reference (i.e., address), a hash code function, or some other method?

I've got my ToDo app to where it displays some mocked-up items, I can scroll them using LazyColumn, and there's a button to the left of the item text which is used to either complete on incomplete item, or to delete a completed / canceled one. I'm working on the first part - completing an item. The code works just fine, I can step through in the debugger and see that the state of the item is being updated in my repository (currently memory-based) and in the state in my view model, however the item on the screen never gets updated.

Weirdly, however, if I scroll the item off the page then back on, it does get displayed as expected (with the title in strikethrough). So something is happening that is causing the item not to be recomposed when the completed state changes, but the state is in fact changed as can be seen when I scroll it off and back on, which forces a recompose.

The structure is like this: MainActivity -> RootPage -> ItemList -> (LazyColumn of) ItemCard

Some relevant code

ItemState
Kotlin code:
data class ItemState(
    val currentFile: ToDoFile?,
    val items: List<ToDoItem>,
    val isLoading: Boolean,
    val errorMessage: String? = null
)
MainViewModel
Kotlin code:
@HiltViewModel
class MainViewModel @Inject constructor(private val toDoRepository: IToDoRepository) : ViewModel() {

    private val _state = mutableStateOf(
        ItemState(
            null,
            emptyList(),
            true,
            null
        )
    )
    val state: State<ItemState>
        get() = _state
...
    val completeItem: (item: ToDoItem) -> Unit = { item ->
        if (!item.complete) {
            var error: String? = null
            val result = toDoRepository.complete(item)
            if (!result.isSuccessResult) {
                error = result.getMessage()
            }
            val newItems = ArrayList<ToDoItem>(toDoRepository.getItems()) // update state with a completely new list
            _state.value = state.value.copy(items = newItems, errorMessage = error) // which SHOULD cause recomposition
        }
    }
MainActivity
Kotlin code:
@AndroidEntryPoint
class MainActivity : ComponentActivity() {

    private val mainViewModel: MainViewModel by viewModels()

    @Inject
    lateinit var toDoFileRepository: IToDoFileRepository

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            ToDoPlusTheme {
                val navController = rememberNavController()
                NavHost(navController = navController, startDestination = "root") {
                    composable("root") {
                        RootPage(
                            state = mainViewModel.state.value,
                            repository = toDoFileRepository,
                            itemCount = mainViewModel.itemCount(),
                            doChoose = mainViewModel.chooseNewList,
                            doNew = mainViewModel.makeNewList,
                            doSearch = mainViewModel.searchItems,
                            doDelete = mainViewModel.deleteItem,
                            doComplete = mainViewModel.completeItem,
                            doEdit = { item -> navController.navigate("edit/${item.id}") }
                        )
                    }
                }
            }
        }
    }
RootPage
Kotlin code:
@Composable
fun RootPage(
    state: ItemState,
    repository: IToDoFileRepository,
    itemCount: Int,
    doChoose: (String) -> Unit,
    doNew: () -> Unit,
    doSearch: () -> Unit,
    doDelete: (item: ToDoItem) -> Unit,
    doComplete: (item: ToDoItem) -> Unit,
    doEdit: (item: ToDoItem) -> Unit
) {
...
        ItemList(
            state = state,
            icons = arrayOf(
                R.drawable.ic_baseline_delete_24,
                R.drawable.ic_baseline_check_24
            ),
            doDelete,
            doComplete,
            doEdit
        )
...
ItemList
Kotlin code:
    if (state.items.isNotEmpty()) {
        LazyColumn(modifier = Modifier.fillMaxSize()) {
            items(state.items) { toDoItem ->
                ItemCard(
                    item = mutableStateOf(toDoItem),
                    buttonIconId = if (toDoItem.complete || toDoItem.canceled) icons[0] else icons[1],
                    onButtonClick = if (toDoItem.complete || toDoItem.canceled) doDelete else doComplete,
                    onItemClick = doEdit
                )
                Divider(color = MaterialTheme.colors.onSurface)
            }
        }
    }
and finally, ItemCard
Kotlin code:
@Composable
fun ItemCard(
    item: MutableState<ToDoItem>,
    buttonIconId: Int,
    onButtonClick: (item: ToDoItem) -> Unit,
    onItemClick: (item: ToDoItem) -> Unit,
    rowHeight: Dp = 40.dp
) {
    Row {
        ItemButton(
            iconid = buttonIconId,
            onClick = { onButtonClick(item.value) }
        )
        Spacer(modifier = Modifier.width(8.dp))
        Row(
            modifier = Modifier
                .clickable { onItemClick(item.value) }
                .fillMaxWidth(),
            verticalAlignment = Alignment.CenterVertically
        ) {
            Text(
                text = item.value.name,
                fontSize = 24.sp,
                fontWeight = if (item.value.dueDate.get(Calendar.YEAR) > 2020) FontWeight.Bold else FontWeight.Normal,
                style =
                if (item.value.complete || item.value.canceled) {
                    TextStyle(textDecoration = TextDecoration.LineThrough)
                } else if (item.value.inProgress) {
                    TextStyle(fontStyle = FontStyle.Italic)
                } else {
                    TextStyle(textDecoration = TextDecoration.None)
                },
                overflow = TextOverflow.Ellipsis
            )
            Spacer(modifier = Modifier.width(4.dp))
            Column(
                horizontalAlignment = Alignment.End,
                modifier = Modifier.weight(1f)
            ) {
                Row {
                    Annotations(item = item.value)
                }
                Text(
                    text = displayDate(item.value.dueDate, SimpleDateFormat("yyyy/MM/dd")),
                    fontSize = 16.sp,
                    fontStyle = FontStyle.Italic,
                    textAlign = TextAlign.End,
                )
            }
        }
    }
}

LongSack
Jan 17, 2003

smiling giraffe posted:

I think you want your viewmodel to expose your UI state in a StateFlow, which is collected in your activity when its in the appropriate lifecycle state. See: https://developer.android.com/kotlin/flow/stateflow-and-sharedflow#stateflow

I think the State class you are using in the viewmodel only causes recomposition within the context of a composable function.

More generally you shouldn't need to inject your repo into the activity and pass it to the composables.

Giving the Activity the Repo you're letting it know to much, the Activity should just be like: "here is some ui state from the viewmodel that I will observe. when a button is clicked i will tell the viewmodel it happened". Then it's the viewmodels job to decide how that button click should result in a change of state.

Thanks, I’ll check that out tomorrow

quote:

Also not sure why you are wrapping ToDoItem in a MutableState

Originally it wasn’t, it was just something i tried to see if that would correct the problem.

EDIT:

I feel like when I first started WPF and didn’t understand data binding. My first version of my character portfolio app literally copied data from the DTO objects to the screen and back. Then one day it just “clicked”. I hope that this happens here too.

LongSack fucked around with this message at 23:18 on Aug 3, 2022

LongSack
Jan 17, 2003

smiling giraffe posted:

I think you want your viewmodel to expose your UI state in a StateFlow, which is collected in your activity when its in the appropriate lifecycle state. See: https://developer.android.com/kotlin/flow/stateflow-and-sharedflow#stateflow

OK based on that page, I changed the state to a (Mutable)StateFlow. Making that change alone didn't change anything. Then I noticed the part about the change in the onCreate method, so I added that:
Kotlin code:
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                mainViewModel.state.collect { state ->
			// WHAT GOES HERE?
                }
            }
        }
...
I can't call a composable function there. I tried moving the entire setContent block into that place, and again -- while it works, the items are still not being updated on the screen until scrolled off and back on again. What am I missing?

LongSack
Jan 17, 2003

brand engager posted:

this spot where you're making a state object further down in the composable is gonna create a new state every time the library checks if it needs to recompose, that's gonna cause some weird issues
Kotlin code:
item = mutableStateOf(toDoItem),

The state object is created in the viewmodel which is injected into the composable, and should be scoped to the lifetime of the composable

quote:

what is this function?
Kotlin code:
items(state.items) { toDoItem ->

That's where the LazyColumn gets its items from

The book I worked through prior to starting this project(Kickstart Modern Android Development with Jetpack and Kotlin, here) develops an app using viewmodels and state and it simply uses a MutableState<T> object (with a public State<T> getter) and it works just by using copy() on it as the state changes, and it seems to work just fine.

LongSack
Jan 17, 2003

brand engager posted:

You're also making one in the spot I quoted though which is a problem for reasons already mentioned

Oh, that part is gone. I had put it in to see if it would help, but it didn't make any difference, so I removed it. It just passes the item now

Edit: To expand on that a bit (as to why I tried that), when doing data binding in WPF everything that the UI binds to is observable. So if I have a list of Foo, not only is the collection observable (using ObservableCollection or ObservableDictionary), but the Foo model is itself completely observable, something like:
C# code:
public class Foo : NotifyBase {
  private int _id;
  public int Id {
    get -> _id;
    set -> SetProperty(ref _id, value);
 }

  private string _name;
  public string Name {
    get -> _name;
    set -> SetProperty(ref _name, value);
  }
So i wanted to try something like that to see if it made any difference. It didn't, so I pulled it out, but I must have posted that question while it was still in the code base

LongSack fucked around with this message at 17:55 on Aug 4, 2022

LongSack
Jan 17, 2003

smiling giraffe posted:

ok try this:
Kotlin code:
    override fun onCreate(savedInstanceState: Bundle?) {
    setContent {
        YourAppTheme {
            val state by mainViewModel.state.collectAsState()
            YourComposable(state)
        }
    }
}
...
I posted this before then got cold feet as I realised it wasn't collecting the flow in a lifecycle aware way. However for the use-case of ui state i think its fine. This is good further reading I think https://manuelvivo.dev/coroutines-addrepeatingjob

No change:
Kotlin code:
        setContent {
            ToDoPlusTheme {
                val navController = rememberNavController()
                val state by mainViewModel.state.collectAsState()
                NavHost(navController = navController, startDestination = "root") {
                    composable("root") {
                        RootPage(
                            state = state,
                            repository = mainViewModel.getToDoFileRepository(),
                            itemCount = mainViewModel.itemCount(),
                            doChoose = mainViewModel.chooseNewList,
                            doNew = mainViewModel.makeNewList,
                            doSearch = mainViewModel.searchItems,
                            doDelete = mainViewModel.deleteItem,
                            doComplete = mainViewModel.completeItem,
                            doEdit = { item -> navController.navigate("edit/${item.id}") }
                        )
                    }
                }
            }
        }

LongSack
Jan 17, 2003

smiling giraffe posted:

whats your viewmodel look like now with the StateFlow

MainViewModel:
Kotlin code:
@HiltViewModel
class MainViewModel @Inject constructor(
    private val toDoRepository: IToDoRepository,
    private val toDoFileRepository: IToDoFileRepository,
) : ViewModel() {

    private val _state = MutableStateFlow(
        ItemState(
            null,
            emptyList(),
            true,
            null
        )
    )
    val state: StateFlow<ItemState>
        get() = _state

    fun getToDoFileRepository(): IToDoFileRepository = toDoFileRepository

    fun isValid() = state.value.currentFile != null

    private fun refreshItems() {
        _state.value = state.value.copy(items = toDoRepository.getItems())
    }

    val chooseNewList: (name: String) -> Unit = { name ->
        Log.i("info", "List names '$name' chosen from drawer")
    }
    val makeNewList: () -> Unit = {
        Log.i("info", "New list button clicked")
    }
    val searchItems: () -> Unit = {
        Log.i("info", "Search button clicked")
    }
    val deleteItem: (item: ToDoItem) -> Unit = { item ->
        Log.i("info", "Delete clicked for ${item.name}")
    }
    val completeItem: (item: ToDoItem) -> Unit = { item ->
        if (!item.complete) {
            var error: String? = null
            val result = toDoRepository.complete(item)
            if (!result.isSuccessResult) {
                error = result.getMessage()
            }
            val newItems = ArrayList<ToDoItem>(toDoRepository.getItems())
            _state.value = state.value.copy(items = newItems, errorMessage = error)
        }
    }

    private fun switchToFile(file: ToDoFile?) {
        if (file == null) {
            _state.value = state.value.copy(
                currentFile = null,
                items = emptyList(),
                isLoading = false,
                errorMessage = "No File Selected"
            )
        } else {
            toDoRepository.load(file)
            refreshItems()
            _state.value = state.value.copy(
                currentFile = file,
                isLoading = false,
                errorMessage = null
            )
        }
    }

    fun switchToFile(listName: String) {
        val file = toDoFileRepository.read(listName)
        switchToFile(file)
    }

    fun itemCount() = state.value.items.size
}

LongSack
Jan 17, 2003

smiling giraffe posted:

Not sure, looks fine to me. Put it on github if you want I’ll have a look

https://github.com/vjkrammes/ToDoPlus

Thanks!

LongSack
Jan 17, 2003

smiling giraffe posted:

The issue is that you're doing IO operations in your repository on the main thread.

If you change your repo functions that access the disk to suspend functions, and then your completeItem function in the viewmodel to this:
Kotlin code:
    val completeItem: (item: ToDoItem) -> Unit = { item ->
        if (!item.complete) {
            viewModelScope.launch {
                withContext(Dispatchers.IO) {
                    var error: String? = null
                    val result = toDoRepository.complete(item)
                    if (!result.isSuccessResult) {
                        error = result.getMessage()
                    }
                    val newItems = ArrayList<ToDoItem>(toDoRepository.getItems())
                    _state.value = state.value.copy(items = newItems, errorMessage = error)
                }
            }
        }
    }
...
it works fine.

Welp, I think I made the changes, but it's still not doing anything different.

The real I/O is in the serializer classes (they are internal to the repository classes). I changed all the methods that actually do the I/O to suspend functions, (and of course made the necessary adjustments up the line). Then in the view models where I need to call the repository methods, I did as above. Nothing is different. It still doesn't update the screen until I scroll an item off an on again.

I also started working on the assignees section, using similar code, and it updates just fine (you can only add assignees at the moment, but they do show up and are persisted to the file system).

I updated the git repo to show the code in its current state.

Also, as an aside, in the suspend functions in the serializers, the I/O calls (openXXX, read, write, readline, close, etc.) are all flagged as "inappropriate blocking method call" so that seems a bit off.

LongSack
Jan 17, 2003

smiling giraffe posted:

override fun getItems(): List<ToDoItem> = items.toList()

That seems to have done the trick! Thank you for all your input.

quote:

Why is StateFlow still in the viewmodel? You don't seem to be using the actual flow parts of it. You're also still recreating state objects on every attempted recompose https://github.com/vjkrammes/ToDoPl...Activity.kt#L31, that's gonna cause problems like mentioned before.

Those changes were made by suggestion. Now that toList() is in place, I was able to change the state back to MutableState and can pass the state objects directly from the view models to the composables:
Kotlin code:
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            ToDoPlusTheme {
                val navController = rememberNavController()
                NavHost(navController = navController, startDestination = "root") {
                    composable("root") {
                        RootPage(
                            state = mainViewModel.state.value,
                            repository = mainViewModel.getToDoFileRepository(),
                            navController = navController,
                            itemCount = mainViewModel.itemCount(),
                            doChoose = mainViewModel.chooseNewList,
                            doNew = mainViewModel.makeNewList,
                            doSearch = mainViewModel.searchItems,
                            doDelete = mainViewModel.deleteItem,
                            doComplete = mainViewModel.completeItem,
                            doEdit = { item -> navController.navigate("edit/${item.id}") }
                        )
                    }
                    composable("assignees") {
                        AssigneePage(
                            state = assigneeViewModel.state.value,
                            doNew = assigneeViewModel.addAssignee,
                            doDelete = assigneeViewModel.deleteAssignee
                        )
                    }
                }
            }
        }
    }

LongSack
Jan 17, 2003

Next batch of questions -

1. I'm using Android Studio (Chipmunk, 2021.2.1 Patch 1), and several times a day it will just stop responding to the keyboard and the mouse wheel. The mouse itself still works, so I can hit the menu and save all before I quit and restart. Not the worst thing in the world, but it does get annoying because it kills the emulator and the restart time is irritating.

2. I was loading up some of my view models in the init block, but since switching my I/O methods to suspense, I can't do that any more. I'm using a factory method and @Provides (similar to how I would handle it in C#), but I'm wondering if there's a better way:
Kotlin code:
@HiltViewModel
class SettingsViewModel @Inject constructor(
    private val settingsRepository: ISettingsRepository
) : ViewModel() {

...

    fun loadSettings() {
        viewModelScope.launch {
            withContext(Dispatchers.IO) {
                val settings = settingsRepository.get()
                withContext(Dispatchers.Main) {
                    _state.value = state.value.copy(
                        settings = settings,
                        isLoading = false
                    )
                }
            }
        }
    }
...

    companion object {
        fun create(
            settingsRepository: ISettingsRepository
        ): SettingsViewModel {
            val vm = SettingsViewModel(settingsRepository)
            vm.loadSettings()
            return vm
        }
    }
}

@Provides
fun settingsViewModelFactory(
     settingsRepository: ISettingsRepository
): SettingsViewModel {
    return SettingsViewModel.create(settingsRepository)
}
3. Navigation issue. I'm mostly navigating using a nav menu in the drawer. This works perfectly. However, some navigation is done via clicks, like clicking on a to do item sends you to a page where you can edit the item. Once I do that, it seems like the "root" page get's stuck to that edit page. So, if I click on an item, I go to the edit screen. If I then choose "Settings" from the nav menu, I go to the settings page. If I then choose "Home" from the nav menu, it takes me back to the edit page. If I click the back arrow, then it "unsticks".

I tried changing the code from a simple navController.navigate("edit/${item.id}") to this:
Kotlin code:
                            doEdit = { item ->
                                navController.navigate("edit/${item.id}") {
                                    navController.graph.startDestinationRoute?.let { route ->
                                        popUpTo(route) {
                                            saveState = true
                                        }
                                    }
                                    launchSingleTop = true
                                    restoreState = true
                                }
                            }
And now the "Home" option on the nav menu does take me to the list of items, but from that point no matter which item I click on, I get sent to the edit page for the original item. Only clicking on the back arrow will unstick it.

Ideas? I have updated the git repo

LongSack
Jan 17, 2003

OK, I figured out the navigation thing. My navigation menu is shown below. The saveState was set to true. This was (not surprisingly) causing the state to be retained between page visits. Setting it to false seems to have corrected the problem.
Kotlin code:
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun NavigationDrawer(
    items: List<NavigationMenuItem>,
    navController: NavController,
    scaffoldState: ScaffoldState,
    scope: CoroutineScope,
    title: @Composable (() -> Unit)? = null,
    modifier: Modifier = Modifier,
) {
    val keyboardController = LocalSoftwareKeyboardController.current
    return Column(
        modifier = modifier.then(Modifier.fillMaxWidth())
    ) {
        if (title != null) {
            title()
            Divider(modifier = Modifier.height(4.dp))
        }
        items.forEach { item ->
            NavigationDrawerItem(item = item, selected = false, onItemClick = {
                keyboardController?.hide()
                navController.navigate(item.route) {
                    navController.graph.startDestinationRoute?.let { route ->
                        popUpTo(route) {
                            saveState = false // this was set to true, this was causing the "stickyness" of the pages
                        }
                    }
                    launchSingleTop = true
                    restoreState = false // so was this, but this parameter is not used if saveState is not true
                }
                scope.launch {
                    scaffoldState.drawerState.close()
                }
            })
        }
    }
}

LongSack
Jan 17, 2003

OK, I've made a ton of progress on my todo app. I showed it to a friend a week or so ago, and she said she'd love something where she could organize items by category. So for a shopping list, you could group meat, produce, etc. and have all the things you need in one area of the store all together. So I've rewritten the code to work as a collection of categorized lists, switched to SQLite (using room) instead of JSON, and now have a basic working version of the app.

I have only one thing that's driving me crazy. I'm using Toast (I will be switching to Snackbar) popups for status reporting ("Item created successfully", "Changes saved successfully", etc.) and it's working on every single page except one. I've looked at the working / non-working code side by side in Android Studio, and I can't see any differences. I've checked that the same imports are being used. I've also run it on my Galaxy S22 in case it's an emulator issue, but the behavior is the same.

I've set some breakpoints, and as best as I can tell, the message(s) are being emitted in the viewmodel, but are not being collected in the page. It doesn't matter whether I use Toast or a Snackbar, the message does not show up. If i put some code inline in the composable as a test, the message shows just fine. I've put the code into a new repository here, but here are the parts in question:

ViewModel:
Kotlin code:
private val _toastMessage = MutableSharedFlow<String>()
val toastMessage = _toastMessage.asSharedFlow()

private fun sendToastMessage(message: String) {
    // Log here prints OK
    viewModelScope.launch {
        // Log here prints OK too
        _toastMessage.emit(message)
    }
}
Page:
Kotlin code:
@Composable
fun NewListPage(
    state: NewListState,
    navController: NavController,
    doSetName: (String) -> Unit,
    doToggleImport: () -> Unit,
    doSelectList: (ToDoList) -> Unit,
    doToggleSwitch: () -> Unit,
    doToggleDefault: () -> Unit,
    doSave: (NavController) -> Unit,
    toastMessage: SharedFlow<String>
) {
			...
    val context = LocalContext.current
    LaunchedEffect(Unit) {
        toastMessage
            .collect { message ->
                // Log here prints nothing
                Toast.makeText(context, message, Toast.LENGTH_LONG).show()
            }
    }
			...
and the MainActivity:
Kotlin code:
composable(route = "newlist",
    enterTransition = {
        standardEnterTransition
        fadeIn(animationSpec = tween(300))
    },
    popExitTransition = {
        standardExitTransition
        fadeOut(animationSpec = tween(300))
    }
) {
        NewListPage(
            state = newListViewModel.state.value,
            navController = navController,
            doSetName = newListViewModel::setName,
            doToggleImport = newListViewModel::toggleImport,
            doSelectList = newListViewModel::selectList,
            doToggleSwitch = newListViewModel::toggleSwitch,
            doToggleDefault = newListViewModel::toggleDefault,
            doSave = newListViewModel::create,
            toastMessage = newItemViewModel.toastMessage
    )
}

LongSack
Jan 17, 2003

smiling giraffe posted:

Have you gone through it step by step in the debugger? That usually works for me

I have. It’s complicated by the use of coroutines, but setting breakpoints in the viewmodel’s sendToastMessage function shows everything as expected. But setting a breakpoint inside the collect never gets tripped. That’s why i think the root of the problem is that messages are being emitted but not collected, for whatever reason

LongSack
Jan 17, 2003

I fixed the toast problem by rewriting the view model. For some reason, it works using the new view model, even though as best as I can tell the code is identical. Code on the left works, code on the right doesn't:

Adbot
ADBOT LOVES YOU

LongSack
Jan 17, 2003

Next piece of weirdness.

If I have a TextField and set the value to an item in the viewmodel state, then the soft keyboard disappears after every keystroke. The field doesn't lose focus, but the keyboard hides. For example:
Kotlin code:
TextField(
    value = state.name,
    onValueChanged = setName
    ...
)

// in viewmodel
fun setName(value: String) {
    _state.value = state.value.copy(name = value)
}
With this setup, the soft keyboard will hide itself after every keystroke. I'm almost certain that it's related to recomposition. As a workaround, I'm using local state and then updating the viewmodel only when the user presses the save / go / whatever key, like this:
Kotlin code:
val name = remember { mutableStateOf("") }

fun changeName(value: String) {
    name.value = value
}

fun save() {
    setName(name.value) // updates state in view model
    doSave() // invoke the save function in the viewmodel
}
It feels like this duplication of state is not the intended way things should work, but it's the only workaround I've found to make things work.

Ideas?

  • 1
  • 2
  • 3
  • 4
  • 5
  • Post
  • Reply