Repository navigation
Abdulrouf/stability plan implementation - #1
Merged
Merged
Conversation
The repository is github.com/abdulroufsidhu/splitcore but both modules declared github.com/abdulroufsidhu/slice_pay, so a plain 'go get' of either module could never resolve.
Linux, Android, and iOS scripts already existed; macOS (universal dylib via lipo) and Windows (mingw-w64 cross-compile) were the two gaps. BUILD.md now covers all five targets, their verification status, and how each output is wired into the Flutter runner. Also narrows .gitignore: the blanket Dart/Flutter `build/` rule was swallowing splitcore/build, which holds the build scripts themselves — only its out/ subtree is generated.
make check is the same gate CI runs, so local green and CI green cannot diverge. Pins the formatter page width to 100 in both analysis_options.yaml files so an IDE format-on-save agrees with make fmt-check.
Mechanical only — no behavior change. Separated from the Makefile commit so the reformat does not obscure it.
The reformat put the bodies on their own lines, which trips curly_braces_in_flow_control_structures under flutter analyze --fatal-infos.
The default was 192.168.240.1 (one developer's Waydroid gateway), contradicting the comment directly above it. Android now defaults to the emulator alias 10.0.2.2, everything else to 127.0.0.1, and --dart-define=POCKETBASE_URL overrides both.
…data Also moves prompt.md into docs/ so the repository root only holds files a newcomer needs on arrival.
Covers overview, architecture, layout, setup, native builds, running the server and app, tests, deployment, and the roadmap. Links to the existing server/ and docs/ material rather than restating it.
Three jobs so a Go failure does not mask a Flutter one. The SDK job consumes the .so built by the Go job as an artifact, since its FFI tests resolve splitcore/build/out/linux/libsplitcore.so by path.
CGO_ENABLED=0 works because PocketBase uses modernc.org/sqlite (pure Go). Compose binds loopback only — TLS termination is the reverse proxy's job, documented in docs/deployment.md along with backup and restore. Verified: image builds, container reports healthy on /api/health, and survives a restart with the pb_data volume intact.
Every list call built its filter by interpolating an id into the filter expression, so a quote in the value could terminate the literal and inject filter syntax. Not exploitable with server-issued ids, but search (next task) puts user input on that exact path. pb.filter escapes the quote rather than stripping it, so the test asserts that no quote inside the literal is left unescaped — checking for the absence of "||" would have been testing the wrong property.
getFullList transferred an entire collection to render one screen. listExpenses/listSettlements now return Page<T>; listAllExpenses and listAllSettlements remain for balance recompute and export, which need the full set. Search binds the user's query through pb.filter — proven by a test that feeds it filter syntax against a real server and gets nothing back rather than every row. App call sites take .items for now; real paging UI lands with the app-usability plan. The global activity feed moves to listAll*, which is what it always wanted.
A partial create left an expense whose splits did not sum to its total. The server skips such an expense in every balance recompute, so it stayed visible, uncounted, and unclearable by the user. split_entries cascade off expenses, so deleting the parent unwinds the whole write. Reproduced first: the test wrote a split referencing a member outside the group and found one orphan expense left behind.
The server already validated and recomputed on expense/split updates; only the client method was missing. Splits are replaced wholesale, so changing the split shape (equal to exact, different members) works. The engine runs before any write, so a rejected SplitSpec leaves the stored expense untouched — covered by a test.
The app refreshes at startup and on every resume, so two refreshes overlap routinely and each one raced to write authStore. Note on the test: asserting "a concurrent refresh returns null" does not reproduce anything — against a live server all three simply succeed. What is provable is the redundancy itself, so the test counts auth-refresh requests on the wire: three overlapping calls hit the server three times before this change and once after. A second test pins that the dedup has not become caching — sequential refreshes still issue two requests.
Request calls swallow not-found so they cannot be used to enumerate accounts; confirm calls throw so a bad token is surfaced to the user. Both need SMTP configured server-side — documented in docs/deployment.md, including the warning that without it the request succeeds and no mail ever arrives.
The planned "settle up, then delete the row" cannot work. Every relation
pointing at group_members (expenses.payer, split_entries.member,
settlements.from/to_member, balances.member) is Required with
CascadeDelete=false, and both group_members.user and groups.owner
reference users the same way. So for anyone who has participated: the
users row will not delete (a membership references it), the membership
will not delete (split entries reference it), and deleting those splits
would rewrite other members' balances and erase shared history.
POST /api/splitcore/delete-account therefore has two outcomes, reported to
the client so the UI can tell the truth about which happened:
deleted - no group owned, no ledger history: memberships removed and
the users row erased
anonymized - otherwise: membership rows kept so balances stay correct,
identity stripped from the users row (tombstone email, no
name/avatar, unverified, unusable password)
Both refuse while any membership carries a non-zero balance.
Owning a group counts as history on its own — found by an SDK test whose
user owned the group they created, which the first Go test missed.
main.dart was the only file in app/lib importing package:pocketbase, and only for one type. The SDK now owns the persistence interface, so the app no longer depends on PocketBase at all (it remains a transitive dependency through splitcore_sdk, which is the point). The persistence test round-trips through a real sign-in against a real server rather than a hand-forged auth blob — the serialized shape is PocketBase's business; what matters is that a session survives relaunch.
Amounts are rendered from int64 minor units by string manipulation — no double is constructed anywhere in the path. Fields are RFC 4180 escaped, and a test unescapes every row to confirm the column count cannot drift when a description contains a comma or a quote.
Every screen hand-rolled a (data, error, loading) triple with no retry path, and none could be widget-tested without a live server because the load was welded into initState. One injected loader fixes both. Two behaviours are pinned by tests because they are the ones easy to get wrong: a reload keeps the previous value on screen instead of blanking to a skeleton, and a *failed* refresh leaves the last good data visible rather than replacing it with an error page.
Removes the duplicated (data, error, loading) triple from three screens and
gives each a retry path it did not have — previously the only recovery from
a failed load was to leave the screen and come back. Each screen now takes
a test-only loader override, so all three are widget-testable without a
server; that is what the 10 new tests use.
Group detail also gains real paging ("show N older expenses") on top of the
SDK's Page<T>. The appended pages are held outside the Loadable's value so
a pull-to-refresh resets to page 1 rather than stacking a stale page under
fresh data — pinned by a test.
Note: `Page` collides with Flutter's navigator Page, so material is
imported with `hide Page` wherever the SDK's paging type is used.
AddExpenseScreen takes an optional existing expense and switches to updateExpense on save, reusing the split editor and live preview rather than duplicating them. Editing restores the original payer instead of defaulting to the signed-in user — pinned by a test, since silently reassigning who paid would corrupt the ledger. Deletion is reached through a long-press sheet and confirmed by a dialog that names the consequence: it recalculates everyone's balances. A swipe gesture was rejected — no confirmation on the way in, and it fights the scroll axis. Adds formatAmountForEditing (minor units -> "42.50" by string manipulation, never through a double) with a round-trip test.
Debounced 300ms — searching per keystroke issues one request per character and lets a slow early response overwrite a later one. An empty query short- circuits to the cached list instead of hitting the server, and no-match shows "No expenses match." rather than the generic empty state. Settlements drop out of the list while searching: they have no description to match on, so leaving them in would look like results that ignore the query.
Reads only, and the banner says so — offline writes need conflict resolution against the group version counter and are not attempted here. The home list writes through to a shared_preferences cache on every successful load and falls back to it when the fetch throws. With nothing cached the error is rethrown, so AsyncSection still offers a retry rather than showing an empty list that looks like "you have no groups". Cached amounts are stored as integer minor units and a corrupt or partial blob is discarded rather than half-read — both pinned by tests.
Reset stays deliberately non-committal ("if that address has an account")
so the button cannot be used to check whether an address is registered —
matching the SDK, which swallows not-found for the same reason.
Account deletion requires typing DELETE, and reports what the server
actually did: when the outcome is "anonymized" it explains that the user
appears in other people's expenses and their records could not be removed
without changing those balances. Claiming a full deletion there would be
a lie.
Export copies CSV to the clipboard rather than adding a share or
file-picker dependency — the destination is a spreadsheet, and paste
covers that in one stdlib call.
Profile editing deliberately stays in the existing home sheet; moving
working code would be churn with no user-visible benefit.
MoneyText encoded owed-vs-owing in colour alone (green/coral plus a +/-
glyph), which is invisible to a screen reader and to anyone who cannot
distinguish the two colours. It now announces the direction in words, with
zero staying neutral.
Avatar defaults to decorative: announcing raw initials ("MS") duplicates a
name that is almost always rendered beside it. The home account button —
where the avatar IS the only affordance — gets an explicit "Account" label
and a 48dp target instead.
Also labels the two remaining unnamed icon buttons (add-invite, close-
receipt). The four Flutter guideline matchers now run against the home and
account screens, not just a synthetic one.
… ConnectivityMonitor A refresh failure was indistinguishable from an expired token, so launching without a connection cleared the auth store and signed the user out. Only 401/403 clears it now.
Reads now come from the local sqlite mirror as streams that re-emit when sync writes. The engine wakes on a connectivity transition or an explicit now(), never on a schedule; the only timer is post-failure backoff. SettlementsApi's recompute-from-log is gone: a pull already produces the same balances from the same server-side numbers, so the second implementation was two things to keep in step.
Every screen now reads the local mirror and re-renders when sync writes, so groups and expenses are there on the first frame and after a relaunch with no connection. The app's own OfflineCache is gone: it was a read-only last-known-good copy of what the local database now holds outright. Expense paging went with it, since local rows have no request to economise on.
Expense carries the server's updated stamp and a pending flag; the outbox holds queued ops with the base they were built on. Conflicting an op also conflicts the ops queued behind it for the same record, which were built on a base the user never saw.
…king Writes commit to the local database and queue an op; the engine drains the queue before pulling and parks anything the server has moved underneath. Three defects this surfaced, all of which lose user data: - The collections declared no autodate fields, so PocketBase never set `updated`. Conflict detection had no base to compare against and every replay would have silently overwritten a co-member's edit. Added an autodate field to expenses, the only records this app updates. - A pull deleted local rows that still had unsent ops behind them, so a write landing while a sync was in flight was erased. Rows with queued work are now protected from the mirror. - sync.now() joined an already-running sync instead of queueing behind it, so a write enqueued mid-run was never in any push snapshot and sat unsent until something else woke the engine. Runs are chained now.
Compare the conflict base as an instant, not a string: PocketBase sends `2026-08-06 16:24:50.448Z` while a value round-tripped through the local database comes back in ISO form with a T, so string equality reported a conflict on every edit — which looks like a working detector until you notice no edit ever syncs. Resolving in the server's favour now resets the group's sync cursor. Dropping the queued op left the abandoned edit on the row, and the staleness check reported the group current, so the server's version was never pulled back over it.
Rows with a queued write render faded; the sync banner reports queue depth and, ahead of everything else, conflicts — the only state that needs a decision from the user rather than patience.
Realtime events carry no payload into the database: an event only marks the engine dirty and the existing version-cursored pull decides what changed. Applying records straight from events would be a second write path with its own ordering, and a client that missed events while backgrounded would silently diverge. Split entry ids are now client-minted like their expense's. The server was assigning its own, so a receipt queued against a local entry id uploaded to a row that did not exist — the photo was silently dropped and the op marked failed.
… improvements feat(server): implement version bumping for group membership changes feat(sdk): optimize sync operations by batching requests for group data feat(sdk): add support for user profile name during sign-up test: add tests for version increments on membership changes and staleness
- Changed app name from "app" to "SlicePay" in various files including Info.plist, CMakeLists.txt, and index.html. - Updated app icons for Android, iOS, macOS, and Windows with new assets. - Added adaptive icons for Android using flutter_launcher_icons. - Introduced a new colors.xml file for Android to define the launcher background color. - Modified the manifest.json for web to reflect the new app name and updated colors. - Enhanced sign-out functionality in main.dart to ensure proper navigation. - Updated pubspec.yaml to include flutter_launcher_icons dependency for icon generation.
- Added a new endpoint `/api/splitcore/remove-member` to handle member removal. - Introduced `removed_at` field in `group_members` to mark members as removed without deleting their records. - Updated hooks to manage member references and prevent deletion if there are outstanding balances. - Enhanced member listing to include removed members for historical context. - Implemented tests for various scenarios including deletion, deactivation, and re-inviting removed members. - Updated database schema to support the new `removed_at` field.
Leaving a group is removal of your own membership, so /api/splitcore/remove-member now also accepts a caller who names their own row rather than requiring group ownership. It is the same operation on the same row: same balance gate, same delete-or-deactivate outcome, same version bump. Only who may ask differs, which is one authorization check rather than a second route carrying a parallel copy of those rules. The owner is refused either way. groups.owner is a required non-cascading relation, and dropping that membership would revoke the owner's own read access to the group, since groups.ListRule is membership-based. In the app, the member sheet reads "Leave group" on your own row, carries its own confirmation copy, and closes the screen behind you once it succeeds — it was showing a group you are no longer in. Each member card now carries an OWNER tag, rendered blank for everyone else so that every card in the horizontal strip keeps the same height.
Ownership was a trap. groups.owner is a required non-cascading relation
and groups.ListRule is membership-based, so the owner's membership row
cannot be dropped without revoking their own access — remove-member
refuses it outright. An owner could neither leave a group nor delete it.
Deleting is gated on the same rule as removing a member and leaving:
refuse while any balance is non-zero. The gate goes in the existing
OnRecordDelete("groups") hook rather than a new route, since that hook is
the one place every delete path already passes through (it also orders
the cascade so populated groups can be deleted at all).
Transferring needs a route: OnRecordUpdateRequest("groups") deliberately
restores the stored owner on every PATCH, so the field is unwritable by
clients by design.
Client-side deletion needed no new plumbing — _pull() already calls
deleteGroupsMissingFrom and the local schema cascades — which is also how
every other member's device learns the group is gone.
The box has under 1 GB of RAM and OOMs on a Go build, so the image has to be built locally and copied over — five manual steps that were being half-run, most recently skipping the backup and the rebuild and then puzzling over a 404 from a route that had never shipped. The verify step probes every custom route unauthenticated and fails on a 404: 401 proves the route is bound and this build is serving, 404 proves the old image still is.
One definition of the breakpoints, so "is this a phone?" is answered the same way everywhere instead of each screen inventing its own number. The meaning the app attaches to each class — rail, panes, inspector — lives next to the widths that trigger it. Width only, deliberately: every layout decision here is about horizontal room, and folding height in would hand a landscape phone a tablet layout for the wrong reason. Nothing consumes this yet.
The app had exactly one response to window size — a 560px centred column — so a 3440px monitor rendered a phone with 1440px of empty paper on each side, and two things could never be on screen at once no matter how much room there was. Below 600px nothing changes: the home screen is still the app and tapping a group still pushes it. Above that a rail carries Groups, Activity and Account; above 840 the group list and one group sit side by side. The detail pane is a nested Navigator rather than a hand-rolled destination switch, which is what lets add_expense and settle_up keep their existing push calls — those land in the pane instead of covering the window, and back works, because that is what a Navigator does.
Three things that still read as a phone app once the shell had room: Sheets. A modal sliding up from the bottom edge of a 1440p window lands hundreds of pixels from where the user was looking and stretches the full width of the monitor to hold two text fields. showAdaptiveSheet keeps the sheet on compact and centres a width-limited dialog above it; call sites change by one word, including their keyboard-inset padding, which the helper strips in dialog form. The members strip. On an ultrawide the members and their balances get a column of their own instead of a horizontal strip the expense list has to scroll past — the one thing you keep glancing back at while reading a ledger. Fixed spacing. A 20px gutter reads as generous on a phone and cramped at 1600px, and PageBody's 560px cap made a 3440px monitor a phone with 1400px of empty paper on each side. Both now come from the size class.
abdulroufsidhu
force-pushed
the
abdulrouf/stability_plan_implementation
branch
from
August 12, 2026 09:06
f0067f4 to
5a03ede
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.