feat: integrate ui with data api

Much of this was ai generated.
This commit is contained in:
talksik
2026-02-06 12:47:44 -08:00
parent ec47e074f6
commit f3e5b90191
15 changed files with 1237 additions and 325 deletions
+3 -2
View File
@@ -29,8 +29,9 @@ qt_add_executable(llink
src/authdialog.ui
src/authdialog.cpp
src/settingsdialog.h src/settingsdialog.cpp src/settingsdialog.ui
src/data.h
src/data.cpp
src/models.h
src/store.h
src/store.cpp
src/networkstreammodel.h
src/networkstreammodel.cpp
src/particlelistmodel.h
+49 -31
View File
@@ -19,7 +19,7 @@
MainWindow::MainWindow(QWidget *parent)
: QMainWindow(parent), m_trayIcon(nullptr), m_authManager(new AuthManager(this)), m_settingsDialog(nullptr),
m_authDialog(nullptr), m_data(nullptr), m_networkStreamModel(nullptr), m_particleListModel(nullptr),
m_authDialog(nullptr), m_store(nullptr), m_networkStreamModel(nullptr), m_particleListModel(nullptr),
m_selectedParticleRow(-1), m_isDragging(false)
{
m_authDialog = new AuthDialog(this);
@@ -95,6 +95,12 @@ MainWindow::MainWindow(QWidget *parent)
connect(m_authManager, &AuthManager::signedIn, m_authDialog, &AuthDialog::reset);
connect(m_authManager, &AuthManager::signedOut, m_authDialog, &AuthDialog::reset);
connect(m_authManager, &AuthManager::signedOut, m_authDialog, &AuthDialog::show);
connect(m_authManager, &AuthManager::signedIn, this, [this]() {
m_store->loadStartupData();
});
connect(m_store, &Store::startupDataLoaded, this, [this]() {
ui->topLevelParticlesView->expandAll();
});
m_authManager->tryRestoreSession();
@@ -143,11 +149,11 @@ void MainWindow::on_settingsButton_clicked()
void MainWindow::setupModels()
{
// Create data with fake data
m_data = new Data(this);
// Create store (data loaded after sign-in via loadStartupData)
m_store = new Store(this);
// Create and set tree model
m_networkStreamModel = new NetworkStreamModel(m_data, this);
m_networkStreamModel = new NetworkStreamModel(m_store, this);
ui->topLevelParticlesView->setModel(m_networkStreamModel);
ui->topLevelParticlesView->setIndentation(5);
ui->topLevelParticlesView->setHeaderHidden(true);
@@ -160,7 +166,7 @@ void MainWindow::setupModels()
ui->topLevelParticlesView->setColumnWidth(1, 40);
// Create and set list model
m_particleListModel = new ParticleListModel(m_data, this);
m_particleListModel = new ParticleListModel(m_store, this);
ui->particlesView->setModel(m_particleListModel);
// Configure particle list view to use ellipses for long text
@@ -175,8 +181,6 @@ void MainWindow::setupModels()
connect(ui->particlesView->selectionModel(), &QItemSelectionModel::selectionChanged, this,
&MainWindow::onParticleSelectionChanged);
// setup human presence list
ui->presenceList->addItems(QStringList{"agnes", "katie", "stephen", "viet", "anthony"});
}
void MainWindow::onTreeSelectionChanged(const QItemSelection &selected, const QItemSelection &deselected)
@@ -186,6 +190,7 @@ void MainWindow::onTreeSelectionChanged(const QItemSelection &selected, const QI
if (selected.indexes().isEmpty())
{
m_particleListModel->setStreamId(QString());
ui->membersList->clear();
return;
}
@@ -202,6 +207,17 @@ void MainWindow::onTreeSelectionChanged(const QItemSelection &selected, const QI
QString networkId = m_networkStreamModel->networkIdFromIndex(parentIndex);
m_selectedNetworkName = networkId;
// Show stream members
ui->membersList->clear();
const Stream *stream = m_store->streamById(streamId);
if (stream)
{
for (const QString &email : stream->memberEmails)
{
ui->membersList->addItem(email.split("@")[0]);
}
}
// Auto-select first unseen particle (or first particle if all seen)
int unseenRow = m_particleListModel->firstUnseenRow();
if (unseenRow >= 0)
@@ -220,6 +236,7 @@ void MainWindow::onTreeSelectionChanged(const QItemSelection &selected, const QI
m_particleListModel->setStreamId(QString());
m_selectedStreamId.clear();
m_selectedParticleRow = -1;
ui->membersList->clear();
QString networkId = m_networkStreamModel->networkIdFromIndex(index);
m_selectedNetworkName = networkId;
@@ -247,9 +264,17 @@ void MainWindow::onParticleSelectionChanged(const QItemSelection &selected, cons
const Particle *particle = m_particleListModel->particleAtRow(index.row());
if (particle)
{
// ui->label->setText(particle->summary);
ui->label_2->setText(particle->createdBy);
ui->label_2->setText(particle->createdByEmail);
m_selectedParticleId = particle->id;
if (particle->type == "text")
{
ui->label->setText(particle->data.value("content").toString());
}
else
{
ui->label->setText("");
}
}
updateStatusBar();
@@ -262,41 +287,30 @@ void MainWindow::updateStatusBar()
// Add network if selected
if (!m_selectedNetworkName.isEmpty())
{
for (const auto &network : m_data->networks())
const Network *network = m_store->networkById(m_selectedNetworkName);
if (network)
{
if (network.id == m_selectedNetworkName)
{
pathComponents << network.name;
break;
}
pathComponents << network->name;
}
}
// Add stream if selected
if (!m_selectedStreamId.isEmpty() && !m_selectedNetworkName.isEmpty())
if (!m_selectedStreamId.isEmpty())
{
const QList<Stream> &streams = m_data->streamsForNetwork(m_selectedNetworkName);
for (const auto &stream : streams)
const Stream *stream = m_store->streamById(m_selectedStreamId);
if (stream)
{
if (stream.id == m_selectedStreamId)
{
pathComponents << stream.name;
break;
}
pathComponents << stream->name;
}
}
// Add particle if selected
if (!m_selectedParticleId.isEmpty() && !m_selectedStreamId.isEmpty())
if (!m_selectedParticleId.isEmpty())
{
const QList<Particle> &particles = m_data->particlesForStream(m_selectedStreamId);
for (const auto &particle : particles)
const Particle *particle = m_store->particleById(m_selectedParticleId);
if (particle)
{
if (particle.id == m_selectedParticleId)
{
pathComponents << particle.id;
break;
}
pathComponents << particle->id;
}
}
@@ -351,9 +365,13 @@ void MainWindow::updatePingIndicator()
int variation = (QRandomGenerator::global()->bounded(21)) - 10; // -10 to +10
int currentPing = basePing + variation;
if (currentPing < 15)
{
currentPing = 15;
}
if (currentPing > 35)
{
currentPing = 35;
}
m_pingLabel->setText("connected " + QString::number(currentPing) + "ms");
}
+2 -2
View File
@@ -5,7 +5,7 @@
#ifndef LLINK_MAINWINDOW_H
#define LLINK_MAINWINDOW_H
#include "data.h"
#include "store.h"
#include "networkstreammodel.h"
#include "particlelistmodel.h"
#include <QMainWindow>
@@ -60,7 +60,7 @@ private:
SettingsDialog *m_settingsDialog;
// Data and models
Data *m_data;
Store *m_store;
NetworkStreamModel *m_networkStreamModel;
ParticleListModel *m_particleListModel;
-160
View File
@@ -1,160 +0,0 @@
#include "data.h"
Data::Data(QObject *parent) : QObject(parent)
{
initializeFakeData();
}
void Data::initializeFakeData()
{
// Network 1: Private
Network net1;
net1.id = "net-private";
net1.name = "Private";
net1.openStreamCount = 2;
net1.openStreamCapacity = 5;
net1.countHumans = 1;
m_networks.append(net1);
// Stream: Personal Notes
Stream stream1;
stream1.id = "stream-notes";
stream1.name = "Personal Notes";
stream1.status = Stream::OPEN;
stream1.unseenCount = 3;
m_streams[net1.id].append(stream1);
// Particles for Personal Notes
m_particles[stream1.id].append({"p1", "text", "viet@flowy.com", "Meeting notes from standup", true});
m_particles[stream1.id].append({"p2", "link", "viet@flowy.com", "API documentation for new endpoint", true});
m_particles[stream1.id].append({"p3", "image", "stephen@flowy.com", "Screenshot of bug in login flow", false});
m_particles[stream1.id].append({"p4", "text", "stephen@flowy.com", "Quick thought about architecture refactor", false});
m_particles[stream1.id].append({"p5", "link", "anthony@flowy.com", "Design inspiration board from Dribbble", false});
// Stream: Quick Captures
Stream stream2;
stream2.id = "stream-captures";
stream2.name = "Quick Captures";
stream2.status = Stream::OPEN;
stream2.unseenCount = 0;
m_streams[net1.id].append(stream2);
// Particles for Quick Captures
m_particles[stream2.id].append({"p6", "text", "alice@flowy.com", "Remember to follow up on client feedback", true});
m_particles[stream2.id].append({"p7", "image", "bob@flowy.com", "Mockup for new feature dashboard", true});
// Network 2: Flowy Labs, Inc
Network net2;
net2.id = "net-flowy";
net2.name = "Flowy Labs, Inc";
net2.openStreamCount = 3;
net2.openStreamCapacity = 10;
net2.countHumans = 12;
m_networks.append(net2);
// Stream: Engineering
Stream stream3;
stream3.id = "stream-eng";
stream3.name = "wild-shape";
stream3.status = Stream::OPEN;
stream3.unseenCount = 10;
m_streams[net2.id].append(stream3);
// Particles for Engineering
m_particles[stream3.id].append({"p8", "text", "charlie@flowy.com", "hey team, anyone seeing issues with the auth service?", true});
m_particles[stream3.id].append({"p9", "text", "dave@flowy.com", "yeah, getting 401s on the staging server", true});
m_particles[stream3.id].append({"p10", "text", "eve@flowy.com", "i think it's the JWT expiration. checking now", true});
m_particles[stream3.id].append({"p11", "link", "eve@flowy.com", "https://github.com/flowy/api/pull/234", true});
m_particles[stream3.id].append({"p12", "text", "eve@flowy.com", "^ PR ready for review. fixed the token refresh logic", false});
m_particles[stream3.id].append({"p13", "text", "frank@flowy.com", "looking at it now", false});
m_particles[stream3.id].append({"p14", "text", "grace@flowy.com", "nice catch! left a few comments about error handling", false});
m_particles[stream3.id].append({"p15", "text", "eve@flowy.com", "updated, should be good to merge", false});
m_particles[stream3.id].append({"p16", "text", "charlie@flowy.com", "merged! deploying to staging now", false});
m_particles[stream3.id].append({"p17", "text", "henry@flowy.com", "btw, we should probably add monitoring for token expiration", false});
m_particles[stream3.id].append({"p18", "text", "dave@flowy.com", "good idea, i can set that up after lunch", false});
m_particles[stream3.id].append({"p19", "text", "ivy@flowy.com", "also, can someone review my database migration PR?", false});
m_particles[stream3.id].append({"p20", "image", "ivy@flowy.com", "here's the schema changes", false});
m_particles[stream3.id].append({"p21", "text", "jack@flowy.com", "looks good to me. just run it by charlie for final approval", false});
// Stream: Design
Stream stream4;
stream4.id = "stream-design";
stream4.name = "arch-rival";
stream4.status = Stream::OPEN;
stream4.unseenCount = 4;
m_streams[net2.id].append(stream4);
// Particles for Design
m_particles[stream4.id].append({"p22", "text", "karen@flowy.com", "starting on the mobile dashboard redesign", true});
m_particles[stream4.id].append({"p23", "image", "karen@flowy.com", "initial mockup - thoughts?", true});
m_particles[stream4.id].append({"p24", "text", "leo@flowy.com", "love the direction! maybe try a darker header?", true});
m_particles[stream4.id].append({"p25", "text", "maya@flowy.com", "agreed, and can we use the new color palette from the design system?", true});
m_particles[stream4.id].append({"p26", "link", "maya@flowy.com", "https://figma.com/design-system-v2", true});
m_particles[stream4.id].append({"p27", "text", "karen@flowy.com", "oh perfect! i'll update it", true});
m_particles[stream4.id].append({"p28", "text", "nathan@flowy.com", "hey, should the settings page follow the same style?", false});
m_particles[stream4.id].append({"p29", "text", "leo@flowy.com", "yes definitely, keeping it consistent across all pages", false});
m_particles[stream4.id].append({"p30", "image", "nathan@flowy.com", "cool, here's my wireframe draft", false});
m_particles[stream4.id].append({"p31", "text", "maya@flowy.com", "looks great! can you add that to the figma file?", false});
// Stream: Archive
Stream stream5;
stream5.id = "stream-archive";
stream5.name = "anthem-project";
stream5.status = Stream::CLOSED;
stream5.unseenCount = 0;
m_streams[net2.id].append(stream5);
// Particles for Archive
m_particles[stream5.id].append({"p32", "text", "oliver@flowy.com", "so we're officially deprecating the old API next week", true});
m_particles[stream5.id].append({"p33", "text", "patricia@flowy.com", "yep, all clients have migrated to v2", true});
m_particles[stream5.id].append({"p34", "text", "quinn@flowy.com", "great work everyone! this project was a long journey", true});
m_particles[stream5.id].append({"p35", "link", "quinn@flowy.com", "https://docs.flowy.com/api/v1-migration-guide", true});
m_particles[stream5.id].append({"p36", "text", "rachel@flowy.com", "should we keep the docs around for a bit?", true});
m_particles[stream5.id].append({"p37", "text", "sam@flowy.com", "yeah i'd say 6 months, then archive everything", true});
m_particles[stream5.id].append({"p38", "text", "tina@flowy.com", "agreed. i'll set a reminder", true});
m_particles[stream5.id].append({"p39", "text", "oliver@flowy.com", "also documented all the lessons learned", true});
m_particles[stream5.id].append({"p40", "text", "patricia@flowy.com", "nice! that'll be super helpful for future projects", true});
m_particles[stream5.id].append({"p41", "text", "quinn@flowy.com", "alright, marking this stream as closed. good job team!", true});
}
const QList<Network>& Data::networks() const
{
return m_networks;
}
const QList<Stream>& Data::streamsForNetwork(const QString &networkId) const
{
static QList<Stream> emptyList;
auto it = m_streams.find(networkId);
if (it == m_streams.end())
{
return emptyList;
}
return it.value();
}
const QList<Particle>& Data::particlesForStream(const QString &streamId) const
{
static QList<Particle> emptyList;
auto it = m_particles.find(streamId);
if (it == m_particles.end())
{
return emptyList;
}
return it.value();
}
int Data::networkCount() const
{
return m_networks.size();
}
int Data::streamCount(const QString &networkId) const
{
return m_streams.value(networkId).size();
}
int Data::particleCount(const QString &streamId) const
{
return m_particles.value(streamId).size();
}
-68
View File
@@ -1,68 +0,0 @@
#ifndef DATA_H
#define DATA_H
#include <QObject>
#include <QMap>
struct Network
{
QString id;
QString name;
int openStreamCount;
int openStreamCapacity;
int countHumans;
};
struct Stream
{
QString id;
QString name;
enum StreamStatus
{
OPEN,
CLOSED,
};
StreamStatus status;
int unseenCount;
};
struct Particle
{
QString id;
QString type;
QString createdBy;
QString summary;
bool seen;
};
class Data : public QObject
{
Q_OBJECT
public:
explicit Data(QObject *parent = nullptr);
// Accessors for models
const QList<Network>& networks() const;
const QList<Stream>& streamsForNetwork(const QString &networkId) const;
const QList<Particle>& particlesForStream(const QString &streamId) const;
int networkCount() const;
int streamCount(const QString &networkId) const;
int particleCount(const QString &streamId) const;
signals:
void networksChanged();
void streamsChanged(const QString &networkId);
void particlesChanged(const QString &streamId);
private:
void initializeFakeData();
QList<Network> m_networks;
// Network -> Streams
QMap<QString, QList<Stream>> m_streams;
// Stream -> Particles
QMap<QString, QList<Particle>> m_particles;
};
#endif // DATA_H
+27 -14
View File
@@ -205,6 +205,19 @@
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Orientation::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QLabel" name="label">
<property name="sizePolicy">
@@ -247,6 +260,19 @@
</property>
</widget>
</item>
<item>
<spacer name="verticalSpacer_3">
<property name="orientation">
<enum>Qt::Orientation::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QLabel" name="label_2">
<property name="font">
@@ -273,19 +299,6 @@
</property>
</widget>
</item>
<item>
<spacer name="verticalSpacer_2">
<property name="orientation">
<enum>Qt::Orientation::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
</item>
@@ -346,7 +359,7 @@
</widget>
</item>
<item>
<widget class="QListWidget" name="presenceList">
<widget class="QListWidget" name="membersList">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
+51
View File
@@ -0,0 +1,51 @@
#ifndef MODELS_H
#define MODELS_H
#include <QObject>
#include <QDateTime>
#include <QMap>
#include <QVariant>
#include <vector>
struct Human
{
// Empty if not a registered human
QString id;
QString email;
QString emailPrefix;
};
struct Stream
{
QString id;
QString name;
QString description;
bool isOpen;
std::vector<QString> memberEmails;
int unseenCount;
};
struct Particle
{
QString id;
QMap<QString, QVariant> data;
QString type;
QString createdByEmail;
bool seen;
std::vector<QString> ackedByEmails;
QDateTime updatedAt;
QDateTime createdAt;
};
struct Network
{
QString id;
QString name;
Human admin;
std::vector<Human> members;
int openStreamCount;
int openStreamCapacity;
std::vector<Stream> streams;
};
#endif //MODELS_H
+31
View File
@@ -67,6 +67,37 @@ QNetworkReply *NetworkManager::post(const QString &path, const QJsonDocument &do
return m_qnam->post(request, doc.toJson());
}
QNetworkReply *NetworkManager::put(const QString &path, const QJsonDocument &doc)
{
QNetworkRequest request = QNetworkRequest(getFullUrl(path));
setAuthHeader(request);
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
return m_qnam->put(request, doc.toJson());
}
QNetworkReply *NetworkManager::patch(const QString &path, const QJsonDocument &doc)
{
QNetworkRequest request = QNetworkRequest(getFullUrl(path));
setAuthHeader(request);
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
return m_qnam->sendCustomRequest(request, "PATCH", doc.toJson());
}
QNetworkReply *NetworkManager::del(const QString &path)
{
QNetworkRequest request = QNetworkRequest(getFullUrl(path));
setAuthHeader(request);
return m_qnam->deleteResource(request);
}
QNetworkReply *NetworkManager::del(const QString &path, const QJsonDocument &doc)
{
QNetworkRequest request = QNetworkRequest(getFullUrl(path));
setAuthHeader(request);
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
return m_qnam->sendCustomRequest(request, "DELETE", doc.toJson());
}
QString NetworkManager::sessionToken() const
{
return m_sessionToken;
+4 -2
View File
@@ -27,8 +27,10 @@ public slots:
// Network manager discard replies internally, in cases where a consumer callsite prefers to not manage QNetworkReply.
QNetworkReply *get(const QString &path);
QNetworkReply *post(const QString &path, const QJsonDocument &data);
// QNetworkReply *put(QNetworkRequest &request);
// QNetworkReply *del(QNetworkRequest &request);
QNetworkReply *put(const QString &path, const QJsonDocument &data);
QNetworkReply *patch(const QString &path, const QJsonDocument &data);
QNetworkReply *del(const QString &path);
QNetworkReply *del(const QString &path, const QJsonDocument &data);
/// @brief sets session token which will is injected into every request's headers
void setSessionToken(const QString &token);
+22 -17
View File
@@ -2,8 +2,16 @@
#include <QBrush>
#include <QColor>
NetworkStreamModel::NetworkStreamModel(Data *data, QObject *parent) : QAbstractItemModel(parent), m_data(data)
NetworkStreamModel::NetworkStreamModel(Store *store, QObject *parent) : QAbstractItemModel(parent), m_store(store)
{
connect(m_store, &Store::networksChanged, this, [this]() {
beginResetModel();
endResetModel();
});
connect(m_store, &Store::streamsChanged, this, [this]() {
beginResetModel();
endResetModel();
});
}
QModelIndex NetworkStreamModel::index(int row, int column, const QModelIndex &parent) const
@@ -59,7 +67,7 @@ int NetworkStreamModel::rowCount(const QModelIndex &parent) const
if (!parent.isValid())
{
// Root level - return number of networks
return m_data->networkCount();
return m_store->networkCount();
}
quintptr id = parent.internalId();
@@ -69,10 +77,9 @@ int NetworkStreamModel::rowCount(const QModelIndex &parent) const
if (networkIndex == 0xFFFFFFFF)
{
int actualNetworkIndex = static_cast<int>(id & 0xFFFFFFFF);
if (actualNetworkIndex >= 0 && actualNetworkIndex < m_data->networks().size())
if (actualNetworkIndex >= 0 && actualNetworkIndex < m_store->networks().size())
{
const QString &networkId = m_data->networks().at(actualNetworkIndex).id;
return m_data->streamCount(networkId);
return static_cast<int>(m_store->networks().at(actualNetworkIndex).streams.size());
}
}
@@ -102,9 +109,9 @@ QVariant NetworkStreamModel::data(const QModelIndex &index, int role) const
if (role == Qt::DisplayRole && index.column() == 0)
{
int actualNetworkIndex = static_cast<int>(id & 0xFFFFFFFF);
if (actualNetworkIndex >= 0 && actualNetworkIndex < m_data->networks().size())
if (actualNetworkIndex >= 0 && actualNetworkIndex < m_store->networks().size())
{
return m_data->networks().at(actualNetworkIndex).name.toUpper();
return m_store->networks().at(actualNetworkIndex).name.toUpper();
}
}
// Networks don't show unseen count in column 1
@@ -114,12 +121,11 @@ QVariant NetworkStreamModel::data(const QModelIndex &index, int role) const
{
// This is a stream item
int streamIndex = static_cast<int>(id & 0xFFFFFFFF);
if (networkIndex >= 0 && networkIndex < m_data->networks().size())
if (networkIndex >= 0 && networkIndex < m_store->networks().size())
{
const QString &networkId = m_data->networks().at(networkIndex).id;
const QList<Stream> &streams = m_data->streamsForNetwork(networkId);
const auto &streams = m_store->networks().at(networkIndex).streams;
if (streamIndex >= 0 && streamIndex < streams.size())
if (streamIndex >= 0 && streamIndex < static_cast<int>(streams.size()))
{
const Stream &stream = streams.at(streamIndex);
@@ -174,12 +180,11 @@ QString NetworkStreamModel::streamIdFromIndex(const QModelIndex &index) const
if (networkIndex != 0xFFFFFFFF)
{
int streamIndex = static_cast<int>(id & 0xFFFFFFFF);
if (networkIndex >= 0 && networkIndex < m_data->networks().size())
if (networkIndex >= 0 && networkIndex < m_store->networks().size())
{
const QString &networkId = m_data->networks().at(networkIndex).id;
const QList<Stream> &streams = m_data->streamsForNetwork(networkId);
const auto &streams = m_store->networks().at(networkIndex).streams;
if (streamIndex >= 0 && streamIndex < streams.size())
if (streamIndex >= 0 && streamIndex < static_cast<int>(streams.size()))
{
return streams.at(streamIndex).id;
}
@@ -203,9 +208,9 @@ QString NetworkStreamModel::networkIdFromIndex(const QModelIndex &index) const
if (networkIndex == 0xFFFFFFFF)
{
int actualNetworkIndex = static_cast<int>(id & 0xFFFFFFFF);
if (actualNetworkIndex >= 0 && actualNetworkIndex < m_data->networks().size())
if (actualNetworkIndex >= 0 && actualNetworkIndex < m_store->networks().size())
{
return m_data->networks().at(actualNetworkIndex).id;
return m_store->networks().at(actualNetworkIndex).id;
}
}
+3 -3
View File
@@ -2,14 +2,14 @@
#define NETWORKSTREAMMODEL_H
#include <QAbstractItemModel>
#include "data.h"
#include "store.h"
class NetworkStreamModel : public QAbstractItemModel
{
Q_OBJECT
public:
explicit NetworkStreamModel(Data *data, QObject *parent = nullptr);
explicit NetworkStreamModel(Store *store, QObject *parent = nullptr);
// Required QAbstractItemModel interface
QModelIndex index(int row, int column, const QModelIndex &parent = QModelIndex()) const override;
@@ -24,7 +24,7 @@ public:
QString networkIdFromIndex(const QModelIndex &index) const;
private:
Data *m_data;
Store *m_store;
};
#endif // NETWORKSTREAMMODEL_H
+24 -23
View File
@@ -5,9 +5,16 @@
#include <QFont>
#include <QStyle>
ParticleListModel::ParticleListModel(Data *data, QObject *parent)
: QAbstractListModel(parent), m_data(data), m_currentStreamId()
ParticleListModel::ParticleListModel(Store *store, QObject *parent)
: QAbstractListModel(parent), m_store(store), m_currentStreamId()
{
connect(m_store, &Store::particlesChanged, this, [this](const QString &streamId) {
if (streamId == m_currentStreamId)
{
beginResetModel();
endResetModel();
}
});
}
int ParticleListModel::rowCount(const QModelIndex &parent) const
@@ -22,7 +29,7 @@ int ParticleListModel::rowCount(const QModelIndex &parent) const
return 0;
}
return m_data->particleCount(m_currentStreamId);
return m_store->particleCount(m_currentStreamId);
}
QVariant ParticleListModel::data(const QModelIndex &index, int role) const
@@ -37,7 +44,7 @@ QVariant ParticleListModel::data(const QModelIndex &index, int role) const
return QVariant();
}
const QList<Particle> &particles = m_data->particlesForStream(m_currentStreamId);
const QList<Particle> &particles = m_store->particlesForStream(m_currentStreamId);
if (index.row() < 0 || index.row() >= particles.size())
{
@@ -48,8 +55,13 @@ QVariant ParticleListModel::data(const QModelIndex &index, int role) const
if (role == Qt::DisplayRole)
{
QString emailPrefix = particle.createdBy.split("@")[0];
QString display = QString("[%1] - %2").arg(emailPrefix, particle.summary);
QString emailPrefix = particle.createdByEmail.split("@")[0];
QString summary = particle.data.value("content").toString();
if (summary.isEmpty())
{
summary = particle.type;
}
QString display = QString("[%1] - %2").arg(emailPrefix, summary);
if (!particle.seen)
{
display += "";
@@ -65,21 +77,6 @@ QVariant ParticleListModel::data(const QModelIndex &index, int role) const
}
// Unseen items use default foreground color
}
// else if (role == Qt::DecorationRole)
// {
// // Return icon based on type
// QStyle::StandardPixmap pixmap;
// if (particle.type == "text")
// pixmap = QStyle::SP_DirIcon;
// else if (particle.type == "link")
// pixmap = QStyle::SP_CommandLink;
// else if (particle.type == "image")
// pixmap = QStyle::SP_MediaPlay;
// else
// return QVariant();
//
// return qApp->style()->standardIcon(pixmap);
// }
return QVariant();
}
@@ -98,7 +95,7 @@ int ParticleListModel::firstUnseenRow() const
return -1;
}
const QList<Particle> &particles = m_data->particlesForStream(m_currentStreamId);
const QList<Particle> &particles = m_store->particlesForStream(m_currentStreamId);
for (int i = 0; i < particles.size(); ++i)
{
if (!particles.at(i).seen)
@@ -113,11 +110,15 @@ int ParticleListModel::firstUnseenRow() const
const Particle *ParticleListModel::particleAtRow(int row) const
{
if (m_currentStreamId.isEmpty())
{
return nullptr;
}
const QList<Particle> &particles = m_data->particlesForStream(m_currentStreamId);
const QList<Particle> &particles = m_store->particlesForStream(m_currentStreamId);
if (row >= 0 && row < particles.size())
{
return &particles.at(row);
}
return nullptr;
}
+3 -3
View File
@@ -2,14 +2,14 @@
#define PARTICLELISTMODEL_H
#include <QAbstractListModel>
#include "data.h"
#include "store.h"
class ParticleListModel : public QAbstractListModel
{
Q_OBJECT
public:
explicit ParticleListModel(Data *data, QObject *parent = nullptr);
explicit ParticleListModel(Store *store, QObject *parent = nullptr);
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
@@ -19,7 +19,7 @@ public:
const Particle* particleAtRow(int row) const;
private:
Data *m_data;
Store *m_store;
QString m_currentStreamId;
};
+879
View File
@@ -0,0 +1,879 @@
#include "store.h"
#include "networkmanager.h"
#include <QDebug>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkReply>
// ────────────────────────────────────────────────────────────────
// Operation
// ────────────────────────────────────────────────────────────────
Operation::Operation(QObject *parent)
: QObject(parent)
{
}
// ────────────────────────────────────────────────────────────────
// Store — construction
// ────────────────────────────────────────────────────────────────
Store::Store(QObject *parent)
: QObject(parent)
{
}
// ────────────────────────────────────────────────────────────────
// Operation helpers
// ────────────────────────────────────────────────────────────────
Operation *Store::makeOperation()
{
auto *op = new Operation(this);
connect(op, &Operation::finished, op, &QObject::deleteLater);
return op;
}
void Store::wireReply(QNetworkReply *reply, Operation *op,
std::function<void(const QJsonDocument &)> successHandler)
{
connect(reply, &QNetworkReply::finished, this, [reply, op, successHandler = std::move(successHandler)]() {
if (reply->error() != QNetworkReply::NoError)
{
QByteArray body = reply->readAll();
QJsonDocument doc = QJsonDocument::fromJson(body);
QString message = doc.isObject() ? doc.object().value("message").toString() : QString();
if (message.isEmpty())
{
message = reply->errorString();
}
emit op->failed(message);
emit op->finished();
return;
}
QByteArray body = reply->readAll();
QJsonDocument doc = body.isEmpty() ? QJsonDocument() : QJsonDocument::fromJson(body);
successHandler(doc);
});
}
void Store::wireReply(QNetworkReply *reply, Operation *op)
{
wireReply(reply, op, [op](const QJsonDocument &) {
emit op->success();
emit op->finished();
});
}
// ────────────────────────────────────────────────────────────────
// JSON parsing
// ────────────────────────────────────────────────────────────────
Human Store::parseHuman(const QJsonObject &obj)
{
Human h;
h.id = obj["id"].toString();
h.email = obj["email"].toString();
h.emailPrefix = obj["email_prefix"].toString();
return h;
}
Network Store::parseNetwork(const QJsonObject &obj)
{
Network n;
n.id = obj["id"].toString();
n.name = obj["name"].toString();
n.admin = parseHuman(obj["admin_human"].toObject());
n.openStreamCount = obj["open_stream_count"].toInt();
n.openStreamCapacity = obj["open_stream_capacity"].toInt();
const QJsonArray humansArr = obj["humans"].toArray();
for (const QJsonValue &v : humansArr)
{
n.members.push_back(parseHuman(v.toObject()));
}
// Streams are parsed at a higher level (populateFromStartupData) so we can
// also extract particles per stream. Individual fetch calls parse streams inline.
return n;
}
Stream Store::parseStream(const QJsonObject &obj)
{
Stream s;
s.id = obj["id"].toString();
s.name = obj["name"].toString();
s.description = obj["description"].toString();
s.isOpen = (obj["status"].toString() == "open");
s.unseenCount = obj["unseen_count"].toInt();
const QJsonArray membersArr = obj["members"].toArray();
for (const QJsonValue &v : membersArr)
{
s.memberEmails.push_back(v.toString());
}
return s;
}
Particle Store::parseParticle(const QJsonObject &obj)
{
Particle p;
p.id = obj["id"].toString();
p.type = obj["type"].toString();
p.createdByEmail = obj["created_by_email"].toString();
p.seen = obj["seen"].toBool();
const QJsonObject dataObj = obj["data"].toObject();
for (auto it = dataObj.begin(); it != dataObj.end(); ++it)
{
p.data[it.key()] = it.value().toVariant();
}
const QJsonArray acksArr = obj["acks"].toArray();
for (const QJsonValue &v : acksArr)
{
// acks can be objects with { email, acked_at } or plain strings
if (v.isObject())
{
p.ackedByEmails.push_back(v.toObject()["email"].toString());
}
else
{
p.ackedByEmails.push_back(v.toString());
}
}
p.updatedAt = QDateTime::fromString(obj["updated_at"].toString(), Qt::ISODate);
p.createdAt = QDateTime::fromString(obj["created_at"].toString(), Qt::ISODate);
return p;
}
void Store::populateFromStartupData(const QJsonObject &root)
{
m_networks.clear();
m_particles.clear();
m_networkIndex.clear();
m_streamToNetwork.clear();
const QJsonArray networksArr = root["networks"].toArray();
for (const QJsonValue &netVal : networksArr)
{
QJsonObject netObj = netVal.toObject();
Network network = parseNetwork(netObj);
const QJsonArray streamsArr = netObj["streams"].toArray();
for (const QJsonValue &streamVal : streamsArr)
{
QJsonObject streamObj = streamVal.toObject();
Stream stream = parseStream(streamObj);
network.streams.push_back(stream);
m_streamToNetwork[stream.id] = network.id;
// Parse particles embedded in stream
QList<Particle> particleList;
const QJsonArray particlesArr = streamObj["particles"].toArray();
for (const QJsonValue &partVal : particlesArr)
{
particleList.append(parseParticle(partVal.toObject()));
}
m_particles[stream.id] = particleList;
}
m_networkIndex[network.id] = m_networks.size();
m_networks.append(network);
}
}
// ────────────────────────────────────────────────────────────────
// State mutation helpers
// ────────────────────────────────────────────────────────────────
void Store::upsertNetwork(const Network &network)
{
if (m_networkIndex.contains(network.id))
{
int idx = m_networkIndex[network.id];
std::vector<Stream> existingStreams = m_networks[idx].streams;
m_networks[idx] = network;
if (network.streams.empty())
{
m_networks[idx].streams = existingStreams;
}
}
else
{
m_networkIndex[network.id] = m_networks.size();
m_networks.append(network);
}
}
void Store::upsertStream(const QString &networkId, const Stream &stream)
{
if (!m_networkIndex.contains(networkId))
{
return;
}
auto &streams = m_networks[m_networkIndex[networkId]].streams;
for (size_t i = 0; i < streams.size(); ++i)
{
if (streams[i].id == stream.id)
{
streams[i] = stream;
m_streamToNetwork[stream.id] = networkId;
return;
}
}
streams.push_back(stream);
m_streamToNetwork[stream.id] = networkId;
}
void Store::upsertParticle(const QString &streamId, const Particle &particle)
{
QList<Particle> &list = m_particles[streamId];
for (int i = 0; i < list.size(); ++i)
{
if (list[i].id == particle.id)
{
list[i] = particle;
return;
}
}
list.append(particle);
}
void Store::removeParticleFromState(const QString &particleId)
{
for (auto it = m_particles.begin(); it != m_particles.end(); ++it)
{
QList<Particle> &list = it.value();
for (int i = 0; i < list.size(); ++i)
{
if (list[i].id == particleId)
{
list.removeAt(i);
return;
}
}
}
}
QString Store::findStreamForParticle(const QString &particleId) const
{
for (auto it = m_particles.constBegin(); it != m_particles.constEnd(); ++it)
{
for (const auto &p : it.value())
{
if (p.id == particleId)
{
return it.key();
}
}
}
return QString();
}
// ────────────────────────────────────────────────────────────────
// Accessors
// ────────────────────────────────────────────────────────────────
const QList<Network> &Store::networks() const { return m_networks; }
int Store::networkCount() const { return m_networks.size(); }
const Network *Store::networkById(const QString &id) const
{
auto it = m_networkIndex.find(id);
if (it == m_networkIndex.end())
{
return nullptr;
}
return &m_networks[it.value()];
}
QList<Stream> Store::streamsForNetwork(const QString &networkId) const
{
auto it = m_networkIndex.find(networkId);
if (it == m_networkIndex.end())
{
return {};
}
const auto &streams = m_networks[it.value()].streams;
QList<Stream> result;
result.reserve(static_cast<int>(streams.size()));
for (const auto &s : streams)
{
result.append(s);
}
return result;
}
int Store::streamCount(const QString &networkId) const
{
auto it = m_networkIndex.find(networkId);
if (it == m_networkIndex.end())
{
return 0;
}
return static_cast<int>(m_networks[it.value()].streams.size());
}
const Stream *Store::streamById(const QString &id) const
{
auto netIt = m_streamToNetwork.find(id);
if (netIt == m_streamToNetwork.end())
{
return nullptr;
}
auto idxIt = m_networkIndex.find(netIt.value());
if (idxIt == m_networkIndex.end())
{
return nullptr;
}
const auto &streams = m_networks[idxIt.value()].streams;
for (const auto &s : streams)
{
if (s.id == id)
{
return &s;
}
}
return nullptr;
}
const QList<Particle> &Store::particlesForStream(const QString &streamId) const
{
static const QList<Particle> empty;
auto it = m_particles.find(streamId);
if (it == m_particles.end())
{
return empty;
}
return it.value();
}
int Store::particleCount(const QString &streamId) const
{
return m_particles.value(streamId).size();
}
const Particle *Store::particleById(const QString &id) const
{
for (auto it = m_particles.constBegin(); it != m_particles.constEnd(); ++it)
{
for (const auto &p : it.value())
{
if (p.id == id)
{
return &p;
}
}
}
return nullptr;
}
// ────────────────────────────────────────────────────────────────
// Bootstrap
// ────────────────────────────────────────────────────────────────
Operation *Store::loadStartupData()
{
Operation *op = makeOperation();
QNetworkReply *reply = NetworkManager::instance().get("/startup");
wireReply(reply, op, [this, op](const QJsonDocument &doc) {
if (!doc.isObject())
{
emit op->failed("Invalid startup data");
emit op->finished();
return;
}
populateFromStartupData(doc.object());
emit startupDataLoaded();
emit networksChanged();
emit op->success();
emit op->finished();
});
return op;
}
// ────────────────────────────────────────────────────────────────
// Network operations
// ────────────────────────────────────────────────────────────────
Operation *Store::createNetwork(const QString &name)
{
Operation *op = makeOperation();
QJsonObject body;
body["name"] = name;
QNetworkReply *reply = NetworkManager::instance().post("/networks", QJsonDocument(body));
wireReply(reply, op, [this, op](const QJsonDocument &doc) {
if (doc.isObject())
{
upsertNetwork(parseNetwork(doc.object()));
emit networksChanged();
}
emit op->success();
emit op->finished();
});
return op;
}
Operation *Store::fetchNetworks()
{
Operation *op = makeOperation();
QNetworkReply *reply = NetworkManager::instance().get("/networks");
wireReply(reply, op, [this, op](const QJsonDocument &doc) {
const QJsonArray arr = doc.array();
for (const QJsonValue &v : arr)
{
upsertNetwork(parseNetwork(v.toObject()));
}
emit networksChanged();
emit op->success();
emit op->finished();
});
return op;
}
Operation *Store::fetchNetwork(const QString &networkId)
{
Operation *op = makeOperation();
QNetworkReply *reply = NetworkManager::instance().get(QString("/networks/%1").arg(networkId));
wireReply(reply, op, [this, op, networkId](const QJsonDocument &doc) {
QJsonObject obj = doc.object();
Network network = parseNetwork(obj);
const QJsonArray streamsArr = obj["streams"].toArray();
for (const QJsonValue &sv : streamsArr)
{
Stream stream = parseStream(sv.toObject());
network.streams.push_back(stream);
m_streamToNetwork[stream.id] = network.id;
}
upsertNetwork(network);
emit networksChanged();
emit op->success();
emit op->finished();
});
return op;
}
Operation *Store::addNetworkMembers(const QString &networkId, const QStringList &emails)
{
Operation *op = makeOperation();
QJsonObject body;
QJsonArray arr;
for (const QString &e : emails)
{
arr.append(e);
}
body["email_addresses"] = arr;
QNetworkReply *reply = NetworkManager::instance().post(
QString("/networks/%1/members").arg(networkId), QJsonDocument(body));
wireReply(reply, op);
return op;
}
Operation *Store::removeNetworkMember(const QString &networkId, const QString &email)
{
Operation *op = makeOperation();
QNetworkReply *reply = NetworkManager::instance().del(
QString("/networks/%1/members/%2").arg(networkId, email));
wireReply(reply, op);
return op;
}
Operation *Store::setStreamCapacity(const QString &networkId, int capacity)
{
Operation *op = makeOperation();
QJsonObject body;
body["capacity"] = capacity;
QNetworkReply *reply = NetworkManager::instance().put(
QString("/networks/%1/capacity").arg(networkId), QJsonDocument(body));
wireReply(reply, op);
return op;
}
// ────────────────────────────────────────────────────────────────
// Stream operations
// ────────────────────────────────────────────────────────────────
Operation *Store::createStream(const QString &networkId, const QString &name,
const QString &description, const QString &visibility,
const QStringList &members)
{
Operation *op = makeOperation();
QJsonObject body;
body["name"] = name;
body["description"] = description;
body["visibility"] = visibility;
QJsonArray membersArr;
for (const QString &m : members)
{
membersArr.append(m);
}
body["members"] = membersArr;
QNetworkReply *reply = NetworkManager::instance().post(
QString("/networks/%1/streams").arg(networkId), QJsonDocument(body));
wireReply(reply, op, [this, op, networkId](const QJsonDocument &doc) {
if (doc.isObject())
{
Stream stream = parseStream(doc.object());
upsertStream(networkId, stream);
emit streamsChanged(networkId);
}
emit op->success();
emit op->finished();
});
return op;
}
Operation *Store::fetchStream(const QString &streamId)
{
Operation *op = makeOperation();
QNetworkReply *reply = NetworkManager::instance().get(QString("/streams/%1").arg(streamId));
wireReply(reply, op, [this, op, streamId](const QJsonDocument &doc) {
QJsonObject obj = doc.object();
Stream stream = parseStream(obj);
QString networkId = m_streamToNetwork.value(streamId);
if (!networkId.isEmpty())
{
upsertStream(networkId, stream);
emit streamsChanged(networkId);
}
// Parse particles if present
const QJsonArray particlesArr = obj["particles"].toArray();
QList<Particle> particleList;
for (const QJsonValue &pv : particlesArr)
{
particleList.append(parseParticle(pv.toObject()));
}
m_particles[streamId] = particleList;
emit particlesChanged(streamId);
emit op->success();
emit op->finished();
});
return op;
}
Operation *Store::updateStream(const QString &streamId, const QString &name, const QString &description)
{
Operation *op = makeOperation();
QJsonObject body;
if (!name.isEmpty())
{
body["name"] = name;
}
if (!description.isEmpty())
{
body["description"] = description;
}
QNetworkReply *reply = NetworkManager::instance().patch(
QString("/streams/%1").arg(streamId), QJsonDocument(body));
wireReply(reply, op, [this, op, streamId](const QJsonDocument &doc) {
if (doc.isObject())
{
Stream stream = parseStream(doc.object());
QString networkId = m_streamToNetwork.value(streamId);
if (!networkId.isEmpty())
{
upsertStream(networkId, stream);
emit streamsChanged(networkId);
}
}
emit op->success();
emit op->finished();
});
return op;
}
Operation *Store::openStream(const QString &streamId)
{
Operation *op = makeOperation();
QNetworkReply *reply = NetworkManager::instance().post(
QString("/streams/%1/open").arg(streamId), QJsonDocument());
wireReply(reply, op, [this, op, streamId](const QJsonDocument &) {
QString networkId = m_streamToNetwork.value(streamId);
if (m_networkIndex.contains(networkId))
{
auto &streams = m_networks[m_networkIndex[networkId]].streams;
for (auto &s : streams)
{
if (s.id == streamId)
{
s.isOpen = true;
break;
}
}
emit streamsChanged(networkId);
}
emit op->success();
emit op->finished();
});
return op;
}
Operation *Store::closeStream(const QString &streamId)
{
Operation *op = makeOperation();
QNetworkReply *reply = NetworkManager::instance().post(
QString("/streams/%1/close").arg(streamId), QJsonDocument());
wireReply(reply, op, [this, op, streamId](const QJsonDocument &) {
QString networkId = m_streamToNetwork.value(streamId);
if (m_networkIndex.contains(networkId))
{
auto &streams = m_networks[m_networkIndex[networkId]].streams;
for (auto &s : streams)
{
if (s.id == streamId)
{
s.isOpen = false;
break;
}
}
emit streamsChanged(networkId);
}
emit op->success();
emit op->finished();
});
return op;
}
Operation *Store::addStreamMembers(const QString &streamId, const QStringList &emails)
{
Operation *op = makeOperation();
QJsonObject body;
QJsonArray arr;
for (const QString &e : emails)
{
arr.append(e);
}
body["emails"] = arr;
QNetworkReply *reply = NetworkManager::instance().post(
QString("/streams/%1/members").arg(streamId), QJsonDocument(body));
wireReply(reply, op);
return op;
}
Operation *Store::removeStreamMembers(const QString &streamId, const QStringList &emails)
{
Operation *op = makeOperation();
QJsonObject body;
QJsonArray arr;
for (const QString &e : emails)
arr.append(e);
body["emails"] = arr;
QNetworkReply *reply = NetworkManager::instance().del(
QString("/streams/%1/members").arg(streamId), QJsonDocument(body));
wireReply(reply, op);
return op;
}
// ────────────────────────────────────────────────────────────────
// Particle operations
// ────────────────────────────────────────────────────────────────
Operation *Store::createParticle(const QString &streamId, const QString &type,
const QMap<QString, QVariant> &data)
{
Operation *op = makeOperation();
QJsonObject body;
body["type"] = type;
QJsonObject dataObj;
for (auto it = data.constBegin(); it != data.constEnd(); ++it)
dataObj[it.key()] = QJsonValue::fromVariant(it.value());
body["data"] = dataObj;
QNetworkReply *reply = NetworkManager::instance().post(
QString("/streams/%1/particles").arg(streamId), QJsonDocument(body));
wireReply(reply, op, [this, op, streamId](const QJsonDocument &doc) {
if (doc.isObject())
{
Particle particle = parseParticle(doc.object());
upsertParticle(streamId, particle);
emit particlesChanged(streamId);
}
emit op->success();
emit op->finished();
});
return op;
}
Operation *Store::fetchParticle(const QString &particleId)
{
Operation *op = makeOperation();
QNetworkReply *reply = NetworkManager::instance().get(QString("/particles/%1").arg(particleId));
wireReply(reply, op, [this, op, particleId](const QJsonDocument &doc) {
if (doc.isObject())
{
Particle particle = parseParticle(doc.object());
QString streamId = findStreamForParticle(particleId);
if (!streamId.isEmpty())
{
upsertParticle(streamId, particle);
emit particlesChanged(streamId);
}
}
emit op->success();
emit op->finished();
});
return op;
}
Operation *Store::updateParticle(const QString &particleId, const QMap<QString, QVariant> &data)
{
Operation *op = makeOperation();
QJsonObject body;
QJsonObject dataObj;
for (auto it = data.constBegin(); it != data.constEnd(); ++it)
dataObj[it.key()] = QJsonValue::fromVariant(it.value());
body["data"] = dataObj;
QNetworkReply *reply = NetworkManager::instance().patch(
QString("/particles/%1").arg(particleId), QJsonDocument(body));
wireReply(reply, op, [this, op, particleId](const QJsonDocument &doc) {
if (doc.isObject())
{
Particle particle = parseParticle(doc.object());
QString streamId = findStreamForParticle(particleId);
if (!streamId.isEmpty())
{
upsertParticle(streamId, particle);
emit particlesChanged(streamId);
}
}
emit op->success();
emit op->finished();
});
return op;
}
Operation *Store::deleteParticle(const QString &particleId)
{
Operation *op = makeOperation();
QNetworkReply *reply = NetworkManager::instance().del(QString("/particles/%1").arg(particleId));
wireReply(reply, op, [this, op, particleId](const QJsonDocument &) {
QString streamId = findStreamForParticle(particleId);
removeParticleFromState(particleId);
if (!streamId.isEmpty())
emit particlesChanged(streamId);
emit op->success();
emit op->finished();
});
return op;
}
Operation *Store::markParticleSeen(const QString &particleId)
{
// Optimistic local update
for (auto it = m_particles.begin(); it != m_particles.end(); ++it)
{
for (int i = 0; i < it.value().size(); ++i)
{
if (it.value()[i].id == particleId)
{
it.value()[i].seen = true;
emit particlesChanged(it.key());
break;
}
}
}
Operation *op = makeOperation();
QNetworkReply *reply = NetworkManager::instance().post(
QString("/particles/%1/seen").arg(particleId), QJsonDocument());
wireReply(reply, op);
return op;
}
Operation *Store::markParticlesSeen(const QStringList &particleIds)
{
// Optimistic local update
QSet<QString> affectedStreams;
for (const QString &pid : particleIds)
{
for (auto it = m_particles.begin(); it != m_particles.end(); ++it)
{
for (int i = 0; i < it.value().size(); ++i)
{
if (it.value()[i].id == pid)
{
it.value()[i].seen = true;
affectedStreams.insert(it.key());
break;
}
}
}
}
for (const QString &streamId : affectedStreams)
emit particlesChanged(streamId);
Operation *op = makeOperation();
QJsonObject body;
QJsonArray ids;
for (const QString &id : particleIds)
ids.append(id);
body["particle_ids"] = ids;
QNetworkReply *reply = NetworkManager::instance().post("/particles/seen", QJsonDocument(body));
wireReply(reply, op);
return op;
}
Operation *Store::ackParticle(const QString &particleId)
{
Operation *op = makeOperation();
QNetworkReply *reply = NetworkManager::instance().post(
QString("/particles/%1/ack").arg(particleId), QJsonDocument());
wireReply(reply, op);
return op;
}
+139
View File
@@ -0,0 +1,139 @@
#ifndef STORE_H
#define STORE_H
#include "models.h"
#include <QList>
#include <QMap>
QT_BEGIN_NAMESPACE
class QNetworkReply;
QT_END_NAMESPACE
/// Represents an in-flight API operation.
/// Callers connect to success/failed for one-off feedback.
/// Automatically deletes itself after finished() fires.
class Operation : public QObject
{
Q_OBJECT
public:
explicit Operation(QObject *parent = nullptr);
signals:
void success();
void failed(const QString &message);
void finished();
};
/// Central data store backed by the Orion API.
/// Holds cached state and exposes read-only accessors + mutating operations.
class Store : public QObject
{
Q_OBJECT
public:
explicit Store(QObject *parent = nullptr);
// ── Read-only accessors ──
const QList<Network> &networks() const;
int networkCount() const;
const Network *networkById(const QString &id) const;
QList<Stream> streamsForNetwork(const QString &networkId) const;
int streamCount(const QString &networkId) const;
const Stream *streamById(const QString &id) const;
const QList<Particle> &particlesForStream(const QString &streamId) const;
int particleCount(const QString &streamId) const;
const Particle *particleById(const QString &id) const;
// ── Bootstrap ──
Operation *loadStartupData();
// ── Network operations ──
Operation *createNetwork(const QString &name);
Operation *fetchNetworks();
Operation *fetchNetwork(const QString &networkId);
Operation *addNetworkMembers(const QString &networkId, const QStringList &emails);
Operation *removeNetworkMember(const QString &networkId, const QString &email);
Operation *setStreamCapacity(const QString &networkId, int capacity);
// ── Stream operations ──
Operation *createStream(const QString &networkId, const QString &name,
const QString &description, const QString &visibility = "network_all",
const QStringList &members = {});
Operation *fetchStream(const QString &streamId);
Operation *updateStream(const QString &streamId, const QString &name, const QString &description);
Operation *openStream(const QString &streamId);
Operation *closeStream(const QString &streamId);
Operation *addStreamMembers(const QString &streamId, const QStringList &emails);
Operation *removeStreamMembers(const QString &streamId, const QStringList &emails);
// ── Particle operations ──
Operation *createParticle(const QString &streamId, const QString &type,
const QMap<QString, QVariant> &data);
Operation *fetchParticle(const QString &particleId);
Operation *updateParticle(const QString &particleId, const QMap<QString, QVariant> &data);
Operation *deleteParticle(const QString &particleId);
Operation *markParticleSeen(const QString &particleId);
Operation *markParticlesSeen(const QStringList &particleIds);
Operation *ackParticle(const QString &particleId);
signals:
// ── Data-change signals ──
void networksChanged();
void streamsChanged(const QString &networkId);
void particlesChanged(const QString &streamId);
void startupDataLoaded();
void errorOccurred(const QString &message);
private:
// ── Internal state ──
QList<Network> m_networks;
QMap<QString, QList<Particle>> m_particles; // streamId -> particles
QMap<QString, int> m_networkIndex; // networkId -> index in m_networks
QMap<QString, QString> m_streamToNetwork; // streamId -> networkId
// ── JSON parsing ──
static Human parseHuman(const QJsonObject &obj);
static Network parseNetwork(const QJsonObject &obj);
static Stream parseStream(const QJsonObject &obj);
static Particle parseParticle(const QJsonObject &obj);
void populateFromStartupData(const QJsonObject &root);
// ── State mutation helpers ──
void upsertNetwork(const Network &network);
void upsertStream(const QString &networkId, const Stream &stream);
void upsertParticle(const QString &streamId, const Particle &particle);
void removeParticleFromState(const QString &particleId);
/// Returns the streamId that contains the given particle, or empty QString.
QString findStreamForParticle(const QString &particleId) const;
// ── Operation helpers ──
/// Creates an Operation parented to this Store, wired to self-destruct after finished().
Operation *makeOperation();
/// Connects reply->finished to a handler. On error, emits op->failed + op->finished.
/// On success, calls successHandler with the parsed QJsonDocument.
/// successHandler is responsible for emitting op->success() and op->finished().
void wireReply(QNetworkReply *reply, Operation *op,
std::function<void(const QJsonDocument &)> successHandler);
/// Simplified wireReply for operations that don't need to parse a response body
/// (e.g. 204 No Content). Emits op->success() and op->finished() automatically.
void wireReply(QNetworkReply *reply, Operation *op);
};
#endif // STORE_H