packages feed

ansido-1.0.0: src/Ansido/App/Update.hs

-- | Pure update of the application state.
--
-- 'update' never touches the terminal. It either returns a new state or a
-- 'DomainError', which the runner turns into a notice on screen.
module Ansido.App.Update
    ( update
    , backAction
    , clampAppState
    ) where

--------------------------------------------------------------------------------
import Data.List (findIndex)
import Data.Maybe (listToMaybe)

import Ansido.App.State
import Ansido.Domain
import Ansido.Ui.Components (Notice (..), NoticeKind (..))

-- | Applies one action to the state.
update :: Action -> AppState -> Either DomainError AppState
update action app =
    case action of
        OpenBoard -> Right (setScreen BoardScreen app)
        OpenHelp -> Right (setScreen HelpScreen app)
        OpenCategories -> Right (setScreen CategoryScreen app)
        OpenDetails -> openDetails app
        OpenEditTask -> openEditTask app
        OpenAddTask -> openAddTask app
        OpenAddCategory -> Right (setScreen AddCategoryScreen app)
        OpenEditCategory -> openEditCategory app
        SelectCategory identifier -> selectCategory identifier app
        MoveRow direction -> Right (moveRow direction app)
        MoveCategory direction -> Right (moveCategory direction app)
        CycleFilter -> Right (keepSelection (selectedTaskId app) (cycleFilter app))
        CycleSort -> Right (keepSelection (selectedTaskId app) (cycleSort app))
        CycleSelected -> cycleSelected app
        DeleteSelected -> deleteSelected app
        DeleteSelectedCategory -> deleteSelectedCategory app
        SubmitTask rawTitle rawContent -> submitTask rawTitle rawContent app
        SubmitEdit identifier rawTitle rawContent ->
            submitEdit identifier rawTitle rawContent app
        SubmitCategory rawName -> submitCategory rawName app
        SubmitCategoryEdit identifier rawName ->
            submitCategoryEdit identifier rawName app
        SetNotice notice -> Right (withNotice notice app)
        ClearNotice -> Right (clearNotice app)

-- | The action performed by the back key on each screen.
backAction :: Screen -> Action
backAction screen =
    case screen of
        BoardScreen -> OpenCategories
        DetailsScreen _ -> OpenBoard
        EditTaskScreen _ -> OpenBoard
        CategoryScreen -> OpenBoard
        HelpScreen -> OpenBoard
        AddTaskScreen -> OpenBoard
        AddCategoryScreen -> OpenCategories
        EditCategoryScreen _ -> OpenCategories

-- Screens.
--------------------------------------------------------------------------------

setScreen :: Screen -> AppState -> AppState
setScreen screen app =
    clampAppState
        ( app
            { appUi = (appUi app){uiScreen = screen, uiNotice = Nothing}
            }
        )

clearNotice :: AppState -> AppState
clearNotice app =
    app{appUi = (appUi app){uiNotice = Nothing}}

openDetails :: AppState -> Either DomainError AppState
openDetails app =
    case selectedTask app of
        Just task ->
            Right (setScreen (DetailsScreen (taskId task)) app)
        Nothing ->
            Right (withNotice (Notice NoticeInfo "No task selected.") app)

openEditTask :: AppState -> Either DomainError AppState
openEditTask app =
    case selectedTask app of
        Just task ->
            Right (setScreen (EditTaskScreen (taskId task)) app)
        Nothing ->
            Right (withNotice (Notice NoticeInfo "No task selected.") app)

openAddTask :: AppState -> Either DomainError AppState
openAddTask app =
    case uiCategory (appUi app) of
        Just _ -> Right (setScreen AddTaskScreen app)
        Nothing ->
            Right (withNotice (Notice NoticeError "Create a category first.") app)

openEditCategory :: AppState -> Either DomainError AppState
openEditCategory app =
    case currentCategory app of
        Just category ->
            Right (setScreen (EditCategoryScreen (categoryId category)) app)
        Nothing ->
            Right (withNotice (Notice NoticeInfo "No category selected.") app)

selectCategory :: CategoryId -> AppState -> Either DomainError AppState
selectCategory identifier app
    | any ((== identifier) . categoryId) (workspaceCategories (appWorkspace app)) =
        Right
            ( clampAppState
                ( app
                    { appUi =
                        (appUi app)
                            { uiScreen = BoardScreen
                            , uiCategory = Just identifier
                            , uiRow = mkRowIndex 0
                            , uiNotice = Nothing
                            }
                    }
                )
            )
    | otherwise = Left (UnknownCategory identifier)

-- Selection.
--------------------------------------------------------------------------------

cycleFilter :: AppState -> AppState
cycleFilter app =
    app
        { appUi =
            (appUi app)
                { uiFilter = nextFilter (uiFilter (appUi app))
                , uiNotice = Nothing
                }
        }

-- | Cycles the table order through original, status and title.
cycleSort :: AppState -> AppState
cycleSort app =
    app
        { appUi =
            (appUi app)
                { uiSort = nextSort (uiSort (appUi app))
                , uiNotice = Nothing
                }
        }

-- | Clamps the state and keeps the given task selected while it is visible.
keepSelection :: Maybe TaskId -> AppState -> AppState
keepSelection identifier app =
    let clamped = clampAppState app
        current = fromIntegral (rowIndexValue (uiRow (appUi clamped)))
        position =
            case identifier >>= \wanted -> findIndex ((== wanted) . taskId) (visibleTasks clamped) of
                Just index -> index
                Nothing -> current
     in clamped{appUi = (appUi clamped){uiRow = mkRowIndex position}}

moveRow :: Direction -> AppState -> AppState
moveRow direction app =
    let current = fromIntegral (rowIndexValue (uiRow (appUi app)))
        limit = max 0 (length (visibleTasks app) - 1)
        next = case direction of
            Previous -> max 0 (current - 1)
            Next -> min limit (current + 1)
     in app{appUi = (appUi app){uiRow = mkRowIndex next}}

-- | Moves the highlighted category without leaving the category screen.
moveCategory :: Direction -> AppState -> AppState
moveCategory direction app =
    app
        { appUi =
            (appUi app)
                { uiCategory = fmap categoryId (indexMaybe next categories)
                , uiRow = mkRowIndex 0
                , uiNotice = Nothing
                }
        }
  where
    categories = workspaceCategories (appWorkspace app)
    limit = max 0 (length categories - 1)
    current =
        case uiCategory (appUi app)
            >>= \identifier -> findIndex ((== identifier) . categoryId) categories of
            Just index -> index
            Nothing -> 0
    next = case direction of
        Previous -> max 0 (current - 1)
        Next -> min limit (current + 1)

-- Tasks.
--------------------------------------------------------------------------------

cycleSelected :: AppState -> Either DomainError AppState
cycleSelected app =
    case selectedTask app of
        Nothing ->
            Right (withNotice (Notice NoticeInfo "No task selected.") app)
        Just task -> do
            let next = nextStatus (taskStatus task)
            workspace <- moveTask (taskId task) next (appWorkspace app)
            let notice =
                    Notice
                        NoticeInfo
                        ("Status changed to " <> statusLabel next <> ".")
            Right
                ( keepSelection
                    (Just (taskId task))
                    ( app
                        { appWorkspace = workspace
                        , appUi = (appUi app){uiNotice = Just notice}
                        }
                    )
                )

deleteSelected :: AppState -> Either DomainError AppState
deleteSelected app =
    case selectedTask app of
        Nothing ->
            Right (withNotice (Notice NoticeInfo "No task selected.") app)
        Just task -> do
            workspace <- deleteTask (taskId task) (appWorkspace app)
            Right
                ( clampAppState
                    ( app
                        { appWorkspace = workspace
                        , appUi =
                            (appUi app)
                                { uiScreen = BoardScreen
                                , uiNotice = Just (Notice NoticeInfo "Task deleted.")
                                }
                        }
                    )
                )

submitEdit ::
    TaskId ->
    String ->
    String ->
    AppState ->
    Either DomainError AppState
submitEdit identifier rawTitle rawContent app =
    case findTask identifier (appWorkspace app) of
        Nothing -> Left (UnknownTask identifier)
        Just task -> do
            title <- mkTaskTitle rawTitle
            content <- mkTaskContent rawContent
            workspace <- editTask identifier title content (appWorkspace app)
            let categoryIdentifier = taskCategory task
                notice = Notice NoticeInfo "Task updated."
            Right
                ( keepSelection
                    (Just identifier)
                    ( app
                        { appWorkspace = workspace
                        , appUi =
                            (appUi app)
                                { uiScreen = BoardScreen
                                , uiCategory = Just categoryIdentifier
                                , uiNotice = Just notice
                                }
                        }
                    )
                )

submitTask :: String -> String -> AppState -> Either DomainError AppState
submitTask rawTitle rawContent app =
    case uiCategory (appUi app) of
        Nothing ->
            Right
                ( withNotice
                    (Notice NoticeError "Create a category first.")
                    (setScreen BoardScreen app)
                )
        Just identifier -> do
            title <- mkTaskTitle rawTitle
            content <- mkTaskContent rawContent
            (task, workspace) <- addTask identifier title content (appWorkspace app)
            let notice =
                    Notice
                        NoticeInfo
                        ("Task added: " <> taskTitleText title)
            Right
                ( keepSelection
                    (Just (taskId task))
                    ( app
                        { appWorkspace = workspace
                        , appUi =
                            (appUi app)
                                { uiScreen = BoardScreen
                                , uiCategory = Just identifier
                                , uiFilter = ShowAll
                                , uiNotice = Just notice
                                }
                        }
                    )
                )

-- Categories.
--------------------------------------------------------------------------------

submitCategory :: String -> AppState -> Either DomainError AppState
submitCategory rawName app = do
    name <- mkCategoryName rawName
    (category, workspace) <- addCategory name (appWorkspace app)
    let notice =
            Notice
                NoticeInfo
                ("Category created: " <> categoryNameText name)
    Right
        ( app
            { appWorkspace = workspace
            , appUi =
                (appUi app)
                    { uiScreen = CategoryScreen
                    , uiCategory = Just (categoryId category)
                    , uiRow = mkRowIndex 0
                    , uiNotice = Just notice
                    }
            }
        )

submitCategoryEdit ::
    CategoryId ->
    String ->
    AppState ->
    Either DomainError AppState
submitCategoryEdit identifier rawName app =
    case findCategory identifier (appWorkspace app) of
        Nothing -> Left (UnknownCategory identifier)
        Just _ -> do
            name <- mkCategoryName rawName
            workspace <- editCategory identifier name (appWorkspace app)
            let notice =
                    Notice
                        NoticeInfo
                        ("Category updated: " <> categoryNameText name)
            Right
                ( clampAppState
                    ( app
                        { appWorkspace = workspace
                        , appUi =
                            (appUi app)
                                { uiScreen = CategoryScreen
                                , uiCategory = Just identifier
                                , uiRow = mkRowIndex 0
                                , uiNotice = Just notice
                                }
                        }
                    )
                )

deleteSelectedCategory :: AppState -> Either DomainError AppState
deleteSelectedCategory app =
    case currentCategory app of
        Nothing ->
            Right (withNotice (Notice NoticeInfo "No category selected.") app)
        Just category -> do
            let identifier = categoryId category
                removed = length (categoryTasks identifier (appWorkspace app))
                suffix = case removed of
                    0 -> ""
                    1 -> " with 1 task"
                    count -> " with " <> show count <> " tasks"
                notice = Notice NoticeInfo ("Category deleted" <> suffix <> ".")
            workspace <- deleteCategory identifier (appWorkspace app)
            Right
                ( clampAppState
                    ( app
                        { appWorkspace = workspace
                        , appUi =
                            (appUi app)
                                { uiScreen = CategoryScreen
                                , uiRow = mkRowIndex 0
                                , uiNotice = Just notice
                                }
                        }
                    )
                )

-- Clamping.
--------------------------------------------------------------------------------

-- | Restores selection invariants after a workspace or screen change.
clampAppState :: AppState -> AppState
clampAppState app =
    app{appUi = clampUiState (appWorkspace app) (appUi app)}

clampUiState :: Workspace -> UiState -> UiState
clampUiState workspace ui =
    let identifier = validCategory workspace (uiCategory ui)
        tasks = case identifier of
            Nothing -> []
            Just categoryIdentifier ->
                filterTasks
                    (uiFilter ui)
                    (categoryTasks categoryIdentifier workspace)
        limit = max 0 (length tasks - 1)
        position =
            min
                (max 0 (fromIntegral (rowIndexValue (uiRow ui))))
                limit
     in ui
            { uiCategory = identifier
            , uiRow = mkRowIndex position
            }

validCategory :: Workspace -> Maybe CategoryId -> Maybe CategoryId
validCategory workspace Nothing =
    fmap categoryId (listToMaybe (workspaceCategories workspace))
validCategory workspace (Just identifier) =
    case findCategory identifier workspace of
        Just _ -> Just identifier
        Nothing -> fmap categoryId (listToMaybe (workspaceCategories workspace))