Finance Manager Example - Part 1

This example represents the part one of the tutorial series on creating a simple Finance Manager that allows users to manage their expenses and visualize them using a pie chart, using PySide6, SQLAlchemy, FastAPI, and Pydantic.

For more details, see the Finance Manager Tutorial - Part 1.

Download this example

# Copyright (C) 2024 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause

import sys
from pathlib import Path
from PySide6.QtWidgets import QApplication
from PySide6.QtQml import QQmlApplicationEngine

from financemodel import FinanceModel  # noqa: F401

if __name__ == '__main__':
    app = QApplication(sys.argv)
    QApplication.setOrganizationName("QtProject")
    QApplication.setApplicationName("Finance Manager")
    engine = QQmlApplicationEngine()

    engine.addImportPath(Path(__file__).parent)
    engine.loadFromModule("Finance", "Main")

    if not engine.rootObjects():
        sys.exit(-1)

    exit_code = app.exec()
    del engine
    sys.exit(exit_code)
# Copyright (C) 2024 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause

from datetime import datetime
from dataclasses import dataclass
from enum import IntEnum
from collections import defaultdict

from PySide6.QtCore import (QAbstractListModel, QEnum, Qt, QModelIndex, Slot,
                            QByteArray)
from PySide6.QtQml import QmlElement

QML_IMPORT_NAME = "Finance"
QML_IMPORT_MAJOR_VERSION = 1


@QmlElement
class FinanceModel(QAbstractListModel):

    @QEnum
    class FinanceRole(IntEnum):
        ItemNameRole = Qt.DisplayRole
        CategoryRole = Qt.UserRole
        CostRole = Qt.UserRole + 1
        DateRole = Qt.UserRole + 2
        MonthRole = Qt.UserRole + 3

    @dataclass
    class Finance:
        item_name: str
        category: str
        cost: float
        date: str

        @property
        def month(self):
            return datetime.strptime(self.date, "%d-%m-%Y").strftime("%B %Y")

    def __init__(self, parent=None) -> None:
        super().__init__(parent)
        self.m_finances = []
        self.m_finances.append(self.Finance("Mobile Prepaid", "Electronics", 20.00, "15-02-2024"))
        self.m_finances.append(self.Finance("Groceries-Feb-Week1", "Groceries", 60.75,
                                            "16-01-2024"))
        self.m_finances.append(self.Finance("Bus Ticket", "Transport", 5.50, "17-01-2024"))
        self.m_finances.append(self.Finance("Book", "Education", 25.00, "18-01-2024"))

    def rowCount(self, parent=QModelIndex()):
        return len(self.m_finances)

    def data(self, index: QModelIndex, role: int):
        row = index.row()
        if row < self.rowCount():
            finance = self.m_finances[row]
            if role == FinanceModel.FinanceRole.ItemNameRole:
                return finance.item_name
            if role == FinanceModel.FinanceRole.CategoryRole:
                return finance.category
            if role == FinanceModel.FinanceRole.CostRole:
                return finance.cost
            if role == FinanceModel.FinanceRole.DateRole:
                return finance.date
            if role == FinanceModel.FinanceRole.MonthRole:
                return finance.month
        return None

    @Slot(result=dict)
    def getCategoryData(self):
        category_data = defaultdict(float)
        for finance in self.m_finances:
            category_data[finance.category] += finance.cost
        return dict(category_data)

    def roleNames(self):
        roles = super().roleNames()
        roles[FinanceModel.FinanceRole.ItemNameRole] = QByteArray(b"item_name")
        roles[FinanceModel.FinanceRole.CategoryRole] = QByteArray(b"category")
        roles[FinanceModel.FinanceRole.CostRole] = QByteArray(b"cost")
        roles[FinanceModel.FinanceRole.DateRole] = QByteArray(b"date")
        roles[FinanceModel.FinanceRole.MonthRole] = QByteArray(b"month")
        return roles

    @Slot(int, result='QVariantMap')
    def get(self, row: int):
        finance = self.m_finances[row]
        return {"item_name": finance.item_name, "category": finance.category,
                "cost": finance.cost, "date": finance.date}

    @Slot(str, str, float, str)
    def append(self, item_name: str, category: str, cost: float, date: str):
        finance = self.Finance(item_name, category, cost, date)
        self.beginInsertRows(QModelIndex(), 0, 0)  # Insert at the front
        self.m_finances.insert(0, finance)  # Insert at the front of the list
        self.endInsertRows()
// Copyright (C) 2024 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts

Dialog {
    id: dialog

    signal finished(string itemName, string category, real cost, string date)

    contentItem: ColumnLayout {
        id: form
        spacing: 10
        property alias itemName: itemName
        property alias category: category
        property alias cost: cost
        property alias date: date

        GridLayout {
            columns: 2
            columnSpacing: 20
            rowSpacing: 10
            Layout.fillWidth: true

            Label {
                text: qsTr("Item Name:")
                Layout.alignment: Qt.AlignLeft | Qt.AlignBaseline
            }

            TextField {
                id: itemName
                focus: true
                Layout.fillWidth: true
                Layout.alignment: Qt.AlignLeft | Qt.AlignBaseline
            }

            Label {
                text: qsTr("Category:")
                Layout.alignment: Qt.AlignLeft | Qt.AlignBaseline
            }

            TextField {
                id: category
                focus: true
                Layout.fillWidth: true
                Layout.alignment: Qt.AlignLeft | Qt.AlignBaseline
            }

            Label {
                text: qsTr("Cost:")
                Layout.alignment: Qt.AlignLeft | Qt.AlignBaseline
            }

            TextField {
                id: cost
                focus: true
                Layout.fillWidth: true
                Layout.alignment: Qt.AlignLeft | Qt.AlignBaseline
                placeholderText: qsTr("€")
                inputMethodHints: Qt.ImhFormattedNumbersOnly
            }

            Label {
                text: qsTr("Date:")
                Layout.alignment: Qt.AlignLeft | Qt.AlignBaseline
            }

            TextField {
                id: date
                Layout.fillWidth: true
                Layout.alignment: Qt.AlignLeft | Qt.AlignBaseline
                // placeholderText: qsTr("dd-mm-yyyy")
                validator: RegularExpressionValidator { regularExpression: /^[0-3]?\d-[01]?\d-\d{4}$/ }
                // code to add the - automatically
                onTextChanged: {
                    if (date.text.length === 2 || date.text.length === 5) {
                        date.text += "-"
                    }
                }
                Component.onCompleted: {
                var today = new Date();
                var day = String(today.getDate()).padStart(2, '0');
                var month = String(today.getMonth() + 1).padStart(2, '0'); // Months are zero-based
                var year = today.getFullYear();
                date.placeholderText = day + "-" + month + "-" + year;
                }
            }
        }
    }

    function createEntry() {
        form.itemName.clear()
        form.category.clear()
        form.cost.clear()
        form.date.clear()
        dialog.title = qsTr("Add Finance Item")
        dialog.open()
    }

    x: parent.width / 2 - width / 2
    y: parent.height / 2 - height / 2

    focus: true
    modal: true
    title: qsTr("Add Finance Item")
    standardButtons: Dialog.Ok | Dialog.Cancel

    Component.onCompleted: {
        dialog.visible = false
        Qt.inputMethod.visibleChanged.connect(adjustDialogPosition)
    }

    function adjustDialogPosition() {
        if (Qt.inputMethod.visible) {
            // If the keyboard is visible, move the dialog up
            dialog.y = parent.height / 4 - height / 2
        } else {
            // If the keyboard is not visible, center the dialog
            dialog.y = parent.height / 2 - height / 2
        }
    }

    onAccepted: {
        finished(form.itemName.text, form.category.text, parseFloat(form.cost.text), form.date.text)
    }
}
// Copyright (C) 2024 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause

import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import QtQuick.Controls.Material

ItemDelegate {
    id: delegate
    checkable: true
    width: parent.width
    height: Qt.platform.os == "android" ?
        Math.min(window.width, window.height) * 0.15 :
        Math.min(window.width, window.height) * 0.1

    contentItem:
    RowLayout {
        Label {
            id: dateLabel
            font.pixelSize: Qt.platform.os == "android" ?
                Math.min(window.width, window.height) * 0.03 :
                Math.min(window.width, window.height) * 0.02
            text: date
            elide: Text.ElideRight
            Layout.fillWidth: true
            Layout.preferredWidth: 1
            color: Material.primaryTextColor
        }

        ColumnLayout {
            spacing: 5
            Layout.fillWidth: true
            Layout.preferredWidth: 1

            Label {
                text: item_name
                color: "#5c8540"
                font.bold: true
                elide: Text.ElideRight
                font.pixelSize:  Qt.platform.os == "android" ?
                    Math.min(window.width, window.height) * 0.03 :
                    Math.min(window.width, window.height) * 0.02
                Layout.fillWidth: true
            }

            Label {
                text: category
                elide: Text.ElideRight
                Layout.fillWidth: true
                font.pixelSize:  Qt.platform.os == "android" ?
                    Math.min(window.width, window.height) * 0.03 :
                    Math.min(window.width, window.height) * 0.02
            }
        }

        Item {
        Layout.fillWidth: true  // This item will take up the remaining space
        }

        ColumnLayout {
            spacing: 5
            Layout.fillWidth: true
            Layout.preferredWidth: 1

            Label {
                text: "you spent:"
                color: "#5c8540"
                elide: Text.ElideRight
                Layout.fillWidth: true
                font.pixelSize:  Qt.platform.os == "android" ?
                    Math.min(window.width, window.height) * 0.03 :
                    Math.min(window.width, window.height) * 0.02
            }

            Label {
                text: cost + "€"
                elide: Text.ElideRight
                Layout.fillWidth: true
                font.pixelSize:  Qt.platform.os == "android" ?
                    Math.min(window.width, window.height) * 0.03 :
                    Math.min(window.width, window.height) * 0.02
            }
        }
    }
}
// Copyright (C) 2024 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause

pragma ComponentBehavior: Bound
import QtQuick
import QtGraphs
import QtQuick.Controls.Material

Item {
    width: Screen.width
    height: Screen.height

    GraphsView {
        id: chart
        anchors.fill: parent
        antialiasing: true

        theme: GraphsTheme {
            colorScheme: Qt.Dark
            theme: GraphsTheme.Theme.QtGreenNeon
        }

        PieSeries {
            id: pieSeries
        }
    }

    Text {
        id: chartTitle
        text: "Total Expenses Breakdown by Category"
        color: "#5c8540"
        font.pixelSize: Qt.platform.os == "android" ?
            Math.min(window.width, window.height) * 0.04 :
            Math.min(window.width, window.height) * 0.03
        anchors.horizontalCenter: parent.horizontalCenter
        anchors.top: parent.top
        anchors.topMargin: 20
    }

    function updateChart(data) {
        pieSeries.clear()
        for (var category in data) {
            var slice = pieSeries.append(category, data[category])
            slice.label = category + ": " + data[category] + "€"
            slice.labelVisible = true
        }
    }
}
// Copyright (C) 2024 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause

import QtQuick
import QtQuick.Controls
import QtQuick.Controls.Material

ListView {
    id: listView
    anchors.fill: parent
    height: parent.height
    property var financeModel

    delegate: FinanceDelegate {
        id: delegate
        width: listView.width
    }

    model: financeModel

    section.property: "month"  // Group items by the "month" property
    section.criteria: ViewSection.FullString
    section.delegate: Component {
        id: sectionHeading
        Rectangle {
            width: listView.width
            height:  Qt.platform.os == "android" ?
                Math.min(window.width, window.height) * 0.05 :
                Math.min(window.width, window.height) * 0.03
            color: "#5c8540"

            required property string section

            Text {
                text: parent.section
                font.bold: true
                // depending on the screen density, adjust the font size
                font.pixelSize: Qt.platform.os == "android" ?
                    Math.min(window.width, window.height) * 0.03 :
                    Math.min(window.width, window.height) * 0.02
                color: Material.primaryTextColor
            }
        }
    }

    ScrollBar.vertical: ScrollBar { }
}
// Copyright (C) 2024 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtQuick.Controls.Material
import Finance

ApplicationWindow {
    id: window
    Material.theme: Material.Dark
    Material.accent: Material.Gray
    width: Screen.width * 0.3
    height: Screen.height * 0.5
    visible: true
    title: qsTr("Finance Manager")

    // Add a toolbar for the application, only visible on mobile
    header: ToolBar {
        Material.primary: "#5c8540"
        visible: Qt.platform.os == "android"
        RowLayout {
            anchors.fill: parent
            Label {
                text: qsTr("Finance Manager")
                font.pixelSize: 20
                Layout.alignment: Qt.AlignCenter
            }
        }
    }

    ColumnLayout {
        anchors.fill: parent

        TabBar {
            id: tabBar
            Layout.fillWidth: true

            TabButton {
                text: qsTr("Expenses")
                font.pixelSize: Qt.platform.os == "android" ?
                    Math.min(window.width, window.height) * 0.04 :
                    Math.min(window.width, window.height) * 0.02
                onClicked: stackView.currentIndex = 0
            }

            TabButton {
                text: qsTr("Charts")
                font.pixelSize: Qt.platform.os == "android" ?
                    Math.min(window.width, window.height) * 0.04 :
                    Math.min(window.width, window.height) * 0.02
                onClicked: stackView.currentIndex = 1
            }
        }

        StackLayout {
            id: stackView
            Layout.fillWidth: true
            Layout.fillHeight: true

            Item {
                id: expensesView
                Layout.fillWidth: true
                Layout.fillHeight: true

                FinanceView {
                    id: financeView
                    anchors.fill: parent
                    financeModel: finance_model
                }
            }

            Item {
                id: chartsView
                Layout.fillWidth: true
                Layout.fillHeight: true

                FinancePieChart {
                    id: financePieChart
                    anchors.fill: parent
                    Component.onCompleted: {
                        var categoryData = finance_model.getCategoryData()
                        updateChart(categoryData)
                    }
                }
            }
        }
    }

    // Model to store the finance data. Created from Python.
    FinanceModel {
        id: finance_model
    }

    // Add a dialog to add new entries
    AddDialog {
        id: addDialog
        onFinished: function(item_name, category, cost, date) {
            finance_model.append(item_name, category, cost, date)
            var categoryData = finance_model.getCategoryData()
            financePieChart.updateChart(categoryData)
        }
    }

    // Add a button to open the dialog
    ToolButton {
        id: roundButton
        text: qsTr("+")
        highlighted: true
        Material.elevation: 6
        width: Qt.platform.os === "android" ?
            Math.min(parent.width * 0.2, Screen.width * 0.15) :
            Math.min(parent.width * 0.060, Screen.width * 0.05)
        height: width  // Keep the button circular
        anchors.margins: 10
        anchors.right: parent.right
        anchors.bottom: parent.bottom
        background: Rectangle {
            color: "#5c8540"
            radius: roundButton.width / 2
        }
        font.pixelSize: width * 0.4
        onClicked: {
            addDialog.createEntry()
        }
    }
}
module Finance
Main 1.0 Main.qml
FinanceView 1.0 FinanceView.qml
FinancePieChart 1.0 FinancePieChart.qml
FinanceDelegate 1.0 FinanceDelegate.qml
AddDialog 1.0 AddDialog.qml