How to solve: Where do you put logic in Flutter?

Question

Asked by user3665287 on November 25, 2021 (source).

I am trying to teach myself Flutter/Dart by programming a sudoku game.

My plan was to have an Object called "Square" to represent each of the 81 squares on the Sudoku grid.

Each Square would manage its own state as regards displaying user inputs and remembering state. So far, I have programmed this using a StatefulWidget.

Where I get stuck is that there also needs to be a top level of game logic which keeps an overview of what is happening with all the squares and deals with interactions. That means I need to be able to query the squares at top level to find out what their state is.

Is there any way to do this with Flutter? Or do I need to go about it with some other structure?

A copy of my current implementation of Square below.

import 'package:flutter/material.dart';
import 'package:sudoku_total/logical_board.dart';

class Square extends StatefulWidget {
  final int squareIndex;
  final int boxIndex;
  final int rowIndex;
  final int colIndex;
  const Square(
    this.squareIndex,
    this.boxIndex,
    this.rowIndex,
    this.colIndex, {
    Key? key,
  }) : super(key: key);

  @override
  State<StatefulWidget> createState() => _SquareState();
}

class _SquareState extends State<Square> {
  int _mainNumber = 0;
  bool showEdit1 = false;
  bool showEdit2 = false;
  bool showEdit3 = false;
  bool showEdit4 = false;
  bool showEdit5 = false;
  bool showEdit6 = false;
  bool showEdit7 = false;
  bool showEdit8 = false;
  bool showEdit9 = false;
  bool _selected = false;
  bool _selectedCollection = false;

  @override
  Widget build(BuildContext context) {
    LogicalBoard.numberButtonNotifier.addListener(() {
      if(LogicalBoard.selectedSquare?.squareIndex == widget.squareIndex){
        setState(() {
          _mainNumber = LogicalBoard.numberLastClicked;
        });
      }
    });
    LogicalBoard.selectionNotifier.addListener(() {
      setState(() {
        _selected =
            LogicalBoard.selectedSquare?.squareIndex == widget.squareIndex;
        _selectedCollection =
            LogicalBoard.selectedSquare?.squareIndex == widget.squareIndex ||
                LogicalBoard.selectedSquare?.rowIndex == widget.rowIndex ||
                LogicalBoard.selectedSquare?.colIndex == widget.colIndex ||
                LogicalBoard.selectedSquare?.boxIndex == widget.boxIndex;
      });
    });
    return Material(
        child: InkWell(
            onTap: () => LogicalBoard.selectionNotifier
                .setSelectedSquare(widget.squareIndex),
            child: Container(
              padding: const EdgeInsets.all(2.0),
              color: _selected
                  ? Theme.of(context).errorColor
                  : Theme.of(context).primaryColorDark,
              width: 52.0,
              height: 52.0,
              child: Container(
                  padding: _mainNumber == 0
                      ? const EdgeInsets.fromLTRB(2.0, 8.0, 2.0, 2.0)
                      : const EdgeInsets.all(0.0),
                  color: _selectedCollection
                      ? Theme.of(context).backgroundColor
                      : Theme.of(context).primaryColor,
                  width: 48.0,
                  height: 48.0,
                  child: _mainNumber != 0
                      ? Center(
                          child: Text(
                          _mainNumber.toString(),
                          style: Theme.of(context).textTheme.headline3,
                        ))
                      : Column(mainAxisSize: MainAxisSize.min, children: [
                          Flexible(
                              child: Row(
                                  mainAxisSize: MainAxisSize.min,
                                  children: [
                                Flexible(
                                    child: Text(
                                  showEdit1 ? "1" : "",
                                  style: Theme.of(context).textTheme.bodyText2,
                                )),
                                Flexible(
                                    child: Text(
                                  showEdit2 ? "2" : "",
                                  style: Theme.of(context).textTheme.bodyText2,
                                )),
                                Flexible(
                                    child: Text(
                                  showEdit3 ? "3" : "",
                                  style: Theme.of(context).textTheme.bodyText2,
                                ))
                              ])),
                          Flexible(
                              child: Row(
                                  mainAxisSize: MainAxisSize.min,
                                  children: [
                                Flexible(
                                    child: Text(
                                  showEdit4 ? "4" : "",
                                  style: Theme.of(context).textTheme.bodyText2,
                                )),
                                Flexible(
                                    child: Text(
                                  showEdit5 ? "5" : "",
                                  style: Theme.of(context).textTheme.bodyText2,
                                )),
                                Flexible(
                                    child: Text(
                                  showEdit6 ? "6" : "",
                                  style: Theme.of(context).textTheme.bodyText2,
                                ))
                              ])),
                          Flexible(
                              child: Row(
                                  mainAxisSize: MainAxisSize.min,
                                  children: [
                                Flexible(
                                    child: Text(
                                  showEdit7 ? "7" : "",
                                  style: Theme.of(context).textTheme.bodyText2,
                                )),
                                Flexible(
                                    child: Text(
                                  showEdit8 ? "8" : "",
                                  style: Theme.of(context).textTheme.bodyText2,
                                )),
                                Flexible(
                                    child: Text(
                                  showEdit9 ? "9" : "",
                                  style: Theme.of(context).textTheme.bodyText2,
                                ))
                              ]))
                        ])),
            )));
  }
}

Thanks for your help.

Answer

Question answered by user3665287 (source).

I found an answer to my question that works, though I'm sure there is more than one way to do it. Also, I'm not even sure if mine is good practice or not, as I am new to Flutter (coming from an OO Java and React background) but here it is.

Square (as seen in my question) has a lot more than just one integer value in state. It has several booleans, several integers and is likely to gain more as I figure out more things that belong in Square. I ended up adapting the model described here https://docs.flutter.dev/development/data-and-backend/state-mgmt/simple#changenotifier

I created a SquareModel which contains all the state information to manage a Square, along with getters and setters where they will be needed. SquareModel extends ChangeNotifier Setters all contain the function notifyListeners(), which is provided by ChangeNotifier and will notify all the listeners (in this case, the Squares) of state changes. SquareModel also contains a function to initialise and return a ChangeNotifierProvider with Square as the output from the builder function:

import 'package:flutter/cupertino.dart';
import 'package:provider/provider.dart';
import 'package:sudoku_total/square.dart';
import 'package:sudoku_total/square_collection.dart';

class SquareModel extends ChangeNotifier {
  final int squareIndex;
  final int rowIndex;
  final int colIndex;
  final int boxIndex;
  LogicalBox? box;
  LogicalRow? row;
  LogicalCol? col;

  SquareModel(this.squareIndex, this.rowIndex, this.colIndex, this.boxIndex);

  int? _mainNumber;
  int? _answer;
  bool _showEdit1 = false;
  bool _showEdit2 = false;
  bool _showEdit3 = false;
  bool _showEdit4 = false;
  bool _showEdit5 = false;
  bool _showEdit6 = false;
  bool _showEdit7 = false;
  bool _showEdit8 = false;
  bool _showEdit9 = false;
  bool _selected = false;
  bool _selectedCollection = false;

  set editNumber(value) {
    switch (value) {
      case 1:
        _showEdit1 = !_showEdit1;
        break;
      case 2:
        _showEdit2 = !_showEdit2;
        break;
      case 3:
        _showEdit3 = !_showEdit3;
        break;
      case 4:
        _showEdit4 = !_showEdit4;
        break;
      case 5:
        _showEdit5 = !_showEdit5;
        break;
      case 6:
        _showEdit6 = !_showEdit6;
        break;
      case 7:
        _showEdit7 = !_showEdit7;
        break;
      case 8:
        _showEdit8 = !_showEdit8;
        break;
      case 9:
        _showEdit9 = !_showEdit9;
    }
    notifyListeners();
  }

  List<int> getEditNumbers() {
    List<int> editNumbers = [];
    if (_showEdit1) {
      editNumbers.add(1);
    }
    if (_showEdit2) {
      editNumbers.add(2);
    }
    if (_showEdit3) {
      editNumbers.add(3);
    }
    if (_showEdit4) {
      editNumbers.add(4);
    }
    if (_showEdit5) {
      editNumbers.add(5);
    }
    if (_showEdit6) {
      editNumbers.add(6);
    }
    if (_showEdit7) {
      editNumbers.add(7);
    }
    if (_showEdit8) {
      editNumbers.add(8);
    }
    if (_showEdit9) {
      editNumbers.add(9);
    }
    return editNumbers;
  }

  int? get mainNumber => _mainNumber;

  set mainNumber(int? value) {
    _mainNumber = value;
    notifyListeners();
  }

  int? get answer => _answer;

  set answer(int? value) {
    _answer = value;
    notifyListeners();
  }

  bool get selected => _selected;

  set selected(bool value) {
    _selected = value;
    notifyListeners();
  }

  bool get selectedCollection => _selectedCollection;

  set selectedCollection(bool value) {
    _selectedCollection = value;
    notifyListeners();
  }

//This last function is called to instantiate the ChangeNotifierProvider for each Square and make sure each Square is provided with the relevant SquareModel state whenever state changes.

getSquare() => ChangeNotifierProvider(
      create: (context) => this,
      child: Consumer<SquareModel>(
        builder: (context, square, child) => Square(
          squareIndex: squareIndex,
          mainNumber: _mainNumber,
          answer: _answer,
          selected: _selected,
          selectedCollection: _selectedCollection,
          showEdit1: _showEdit1,
          showEdit2: _showEdit2,
          showEdit3: _showEdit3,
          showEdit4: _showEdit4,
          showEdit5: _showEdit5,
          showEdit6: _showEdit6,
          showEdit7: _showEdit7,
          showEdit8: _showEdit8,
          showEdit9: _showEdit9,
        ),
      ));

  
}

I have an abstract class LogicalBoard which instantiates all 81 of the SquareModels and holds them in a static List so they are available to any other object in the application. LogicalBoard provides methods to manage the state and sets up the logic behind the sudoku board (for example, which SquareModels belong to which columns, rows and boxes). LogicalBoard also creates a list of 9 SudokuRow which are a StatefulWidget, each of which takes 9 Squares and displays them with the appropriate gaps. This list is iterated when the Sudoku board is set up in order to display the board.

import 'package:sudoku_total/square_model.dart';
import 'package:sudoku_total/sudoku_row.dart';
import 'package:sudoku_total/square_collection.dart';

class LogicalBoard {
  static final List<SquareModel> squareModels = _initSquares();
  static final List<SudokuRow> rowsWidgets = _initRowsWidgets();
  static final List<LogicalBox> boxes = _initBoxes();
  static final List<LogicalRow> rows = _initRows();
  static final List<LogicalCol> cols = _initCols();

  static SquareModel? selectedSquare;
  static int numberLastClicked=0;

  static void setNumber(int number){
    if(selectedSquare != null){
      selectedSquare?.mainNumber = number;
    }

  }

  static void setSelectedSquare(int squareIndex){
    selectedSquare = squareModels[squareIndex];
    for (var sm in squareModels) {
      sm.selected = (sm.squareIndex==squareIndex);
      sm.selectedCollection = sm.boxIndex==selectedSquare?.boxIndex || sm.rowIndex==selectedSquare?.rowIndex || sm.colIndex==selectedSquare?.colIndex;
    }
  }



  static List<LogicalBox> _initBoxes(){
    List<LogicalBox> boxes = [];
    for(var i = 0; i<9; i++){
      boxes.add(LogicalBox(_getBoxSquares(i)));
    }
    return List.unmodifiable(boxes);
  }

  static List<LogicalRow> _initRows(){
    List<LogicalRow> rows = [];
    for(var i = 0; i<9; i++){
      rows.add(LogicalRow(_getRowSquares(i)));
    }
    return List.unmodifiable(rows);
  }

  static List<LogicalCol> _initCols(){
    List<LogicalCol> boxes = [];
    for(var i = 0; i<9; i++){
      boxes.add(LogicalCol(_getColSquares(i)));
    }
    return List.unmodifiable(boxes);
  }

  static List<SudokuRow> _initRowsWidgets() {
    List<SudokuRow> rows = [];
    for(var i = 0; i<9; i++){
      rows.add(SudokuRow(_getRowSquares(i), i));
    }
    return List.unmodifiable(rows);
  }

  static List<SquareModel> _getRowSquares(int rowIndex){
    return List.unmodifiable(squareModels.where((element) => element.rowIndex == rowIndex));
  }

  static List<SquareModel> _getColSquares(int colIndex){
    return List.unmodifiable(squareModels.where((element) => element.colIndex == colIndex));
  }

  static List<SquareModel> _getBoxSquares(int boxIndex){
    return List.unmodifiable(squareModels.where((element) => element.boxIndex == boxIndex));
  }

  static List<SquareModel> _initSquares() {
    List<SquareModel> initSquares = [];
    //Initialise squares with squareIndex, rowIndex, colIndex and boxIndex
    int squareIndex = 0;
    int rowIndex = 0;
    int colStartIndex = 0;
    int colIndex = 0;
    int boxStartIndex = 0;
    int boxIndex = 0;
    for (var count = 1; count < 82; count++) {
      //Add row, col and box index to the new square
      initSquares.add(SquareModel(squareIndex, rowIndex, colIndex, boxIndex));
      //Every square
      //col index increments
      colIndex++;
      //square index increments
      squareIndex++;

      //Every 3 squares
      if (count % 3 == 0) {
        //Box index increments
        boxIndex++;
      }

      //Every 9 squares
      if (count % 9 == 0) {
        //col index goes back to start
        colIndex = colStartIndex;
        //row index increments
        rowIndex++;
        //Box index goes back to the start
        boxIndex = boxStartIndex;
      }

      //Every 27 squares
      if (count % 27 == 0) {
        //Box start index increments by 3
        boxStartIndex = boxStartIndex + 3;
        boxIndex = boxStartIndex;
      }
    }
    return List.unmodifiable(initSquares);
  }

}

Most of the functions in LogicalBoard at the moment are related to setting up the board. However, notice the functions setNumber and setSelectedSquare which demonstrate how easily the state management works with this structure.

I wanted to add functionality whereby a user clicks on a Square, and the border colour of the Square changes to show that it is the 'selected' Square. With the ChangeNotifier setup described, this only took three small changes:

  1. I added the function setSelectedSquare to LogicalBoard. It iterates all the SquareModels and makes sure all the SquareModel states have false for _selected except the SquareModel for the Square the user clicked, which will have true for _selected. It also sets the value of selectedSquare on LogicalBoard to the SquareModel for the Square the user clicked so that it is remembered and can be used in future.
  2. I updated the code for Square with an Inkwell widget with an onTap function. Notice that the Inkwell onTap function calls LogicalBoard.setSelectedSquare.
  3. I made the colour (I'm British, we spell colour 'colour') of the first Container widget (which defines the border colour of the Square) dependent on the value of _selected

Here is the code for Square

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';

import 'logical_board.dart';

class Square extends StatelessWidget {
  final int _squareIndex;
  final int? _mainNumber;
  final int? _answer;
  final bool _showEdit1;
  final bool _showEdit2;
  final bool _showEdit3;
  final bool _showEdit4;
  final bool _showEdit5;
  final bool _showEdit6;
  final bool _showEdit7;
  final bool _showEdit8;
  final bool _showEdit9;
  final bool _selected;
  final bool _selectedCollection;
  const Square({
    Key? key,
    required int squareIndex,
    required int? mainNumber,
    required int? answer,
    required bool showEdit1,
    required bool showEdit2,
    required bool showEdit3,
    required bool showEdit4,
    required bool showEdit5,
    required bool showEdit6,
    required bool showEdit7,
    required bool showEdit8,
    required bool showEdit9,
    required bool selected,
    required bool selectedCollection,
  })  : _squareIndex = squareIndex,
        _mainNumber = mainNumber,
        _answer = answer,
        _showEdit1 = showEdit1,
        _showEdit2 = showEdit2,
        _showEdit3 = showEdit3,
        _showEdit4 = showEdit4,
        _showEdit5 = showEdit5,
        _showEdit6 = showEdit6,
        _showEdit7 = showEdit7,
        _showEdit8 = showEdit8,
        _showEdit9 = showEdit9,
        _selected = selected,
        _selectedCollection = selectedCollection,
        super(key: key);

  @override
  Widget build(BuildContext context) {
    return Material(
        child: InkWell(
            onTap: () => LogicalBoard.setSelectedSquare(_squareIndex),
            child: Container(
              padding: const EdgeInsets.all(2.0),
              color: _selected
                  ? Theme.of(context).errorColor
                  : Theme.of(context).primaryColorDark,
              width: 52.0,
              height: 52.0,
              child: Container(
                  padding: _mainNumber == null
                      ? const EdgeInsets.fromLTRB(2.0, 8.0, 2.0, 2.0)
                      : const EdgeInsets.all(0.0),
                  color: _selectedCollection
                      ? Theme.of(context).backgroundColor
                      : Theme.of(context).primaryColor,
                  width: 48.0,
                  height: 48.0,
                  child: _mainNumber != null
                      ? Center(
                          child: Text(
                          _mainNumber.toString(),
                          style: Theme.of(context).textTheme.headline3,
                        ))
                      : Column(mainAxisSize: MainAxisSize.min, children: [
                          Flexible(
                              child: Row(
                                  mainAxisSize: MainAxisSize.min,
                                  children: [
                                Flexible(
                                    child: Text(
                                  _showEdit1 ? "1" : "",
                                  style: Theme.of(context).textTheme.bodyText2,
                                )),
                                Flexible(
                                    child: Text(
                                  _showEdit2 ? "2" : "",
                                  style: Theme.of(context).textTheme.bodyText2,
                                )),
                                Flexible(
                                    child: Text(
                                  _showEdit3 ? "3" : "",
                                  style: Theme.of(context).textTheme.bodyText2,
                                ))
                              ])),
                          Flexible(
                              child: Row(
                                  mainAxisSize: MainAxisSize.min,
                                  children: [
                                Flexible(
                                    child: Text(
                                  _showEdit4 ? "4" : "",
                                  style: Theme.of(context).textTheme.bodyText2,
                                )),
                                Flexible(
                                    child: Text(
                                  _showEdit5 ? "5" : "",
                                  style: Theme.of(context).textTheme.bodyText2,
                                )),
                                Flexible(
                                    child: Text(
                                  _showEdit6 ? "6" : "",
                                  style: Theme.of(context).textTheme.bodyText2,
                                ))
                              ])),
                          Flexible(
                              child: Row(
                                  mainAxisSize: MainAxisSize.min,
                                  children: [
                                Flexible(
                                    child: Text(
                                  _showEdit7 ? "7" : "",
                                  style: Theme.of(context).textTheme.bodyText2,
                                )),
                                Flexible(
                                    child: Text(
                                  _showEdit8 ? "8" : "",
                                  style: Theme.of(context).textTheme.bodyText2,
                                )),
                                Flexible(
                                    child: Text(
                                  _showEdit9 ? "9" : "",
                                  style: Theme.of(context).textTheme.bodyText2,
                                ))
                              ]))
                        ])),
            )));
  }
}

The function setNumber in LogicalBoard is used in a similar way to update the state of the selected Square (if there is one) to the number on a number button which has just been clicked.

By the way, it was a complete eye-opener to me to find that Flutter is quite happy to re-render the widgets every state change rather than trying to minimise re-renders (as per React)

Video Answers on YouTube

DART FLUTTER LOGIC OOP
SHARE: