This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
QuaX (formerly Quacker) is a privacy-focused Flutter/Dart client for X (formerly Twitter), forked from Quacker/Fritter. It has no external trackers, stores all data locally in SQLite, and uses reverse-engineered X API endpoints.
Use fvm flutter instead of raw flutter to enforce the pinned SDK version (3.44.1).
# Install the pinned Flutter SDK and activate it for this project
fvm install
fvm use
# Generate launcher icon assets
python -mvenv .venv
bash -c '
source ./.venv/bin/activate
pip install -r requirements.txt
python generate_icons.py
'
# Run all build steps through fvm so the pinned SDK is used
fvm flutter pub get
fvm dart run flutter_launcher_icons
fvm dart run dart_pubspec_licenses:generate
fvm dart run intl_utils:generate
fvm dart run flutter_iconpicker:generate_packs --packs material
fvm flutter build apk --debugAll state is managed with flutter_triple Store<T> objects. Each feature has a *_model.dart that extends Store and uses execute() for async operations. UI widgets observe these stores via ScopedBuilder / TripleBuilder. Do not use setState or ChangeNotifier — use the Store pattern throughout.
Each feature folder contains its screen(s) and its model:
| Folder | Description |
|---|---|
client/ |
X API client wrappers (authenticated + unauthenticated) |
database/ |
SQLite repository, entity classes, schema migrations |
home/ |
Home screen with tab navigation |
profile/ |
User profile view |
tweet/ |
Tweet card rendering, threads, video playback |
search/ |
Search for tweets and users |
trends/ |
Trending topics |
subscriptions/ |
Followed users management |
group/ |
Subscription groups (custom feeds) |
saved/ |
Offline saved tweets |
settings/ |
App preferences |
utils/ |
Shared helpers (downloads, caching, deep linking) |
generated/ |
Auto-generated localization — do not edit manually |
The X API is reverse-engineered — endpoints, tokens, and headers may change without notice. Always use safe null-coalescing access when parsing JSON responses:
// Good — safe against missing fields
final text = result["data"]?["text"] as String?;
final count = result["legacy"]?["favorite_count"] as int? ?? 0;
// Bad — will throw if field is absent
final text = result["data"]["text"] as String;client.dart wraps dart_twitter_api and adds caching via FFCache. client_unauthenticated.dart uses a hardcoded bearer token from constants.dart; client_regular_account.dart uses stored OAuth credentials.
Account selection strategy. _QuackerTwitterClient.fetch() in client.dart does not pick a random account — it asks AccountSelector (account_selector.dart, a pure/testable policy) for a healthy account, then retries on another account on error. Two distinct health signals:
- Rate limit (
429) is per-endpoint (X rate-limits per endpoint, not per account). It is tracked in memory byRateLimitTracker(rate_limit_tracker.dart), keyed by(accountId, uri.path), with the reset time from X'sx-rate-limit-resetheader (elserateLimitFallback). Not persisted — windows are short. The selector receives this via an injectedisRateLimitedpredicate. - Not-found (
404) is per-account and persisted (auth likely broken): flagged afternotFoundThresholdconsecutive 404s, fornotFoundCooldown. HelpersrecordNotFound/recordAccountSuccesslive inaccounts.dart; cooldown constants inconstants.dart.
AccountSelector.pick() prefers healthy accounts but falls back to flagged ones, so a real request is always attempted while any account exists — the flags only influence ordering, they never short-circuit. Errors therefore surface only from actual responses, each with a dedicated widget in ui/errors.dart (all built on the shared ActionableErrorWidget, offering add-account + retry):
- every tried account was rate-limited on the endpoint →
RateLimitedException(⏳); - every tried account returned 404 (likely broken auth) →
NoWorkingAccountException(🤷); - there is no account at all → an unauthenticated (guest) request is attempted first;
NoAccountAvailableException(🔑) is thrown only if that guest request also fails.
Any other error response is surfaced as-is via HttpException. Retry simply re-runs fetch(), which always attempts a real request before surfacing any error.
repository.dart is the single access point for SQLite (via sqflite). Schema changes must go through sqflite_migration_plan migrations — never alter the schema outside of a migration. Key entities: Subscription, SubscriptionGroup, SavedTweet, Account (carries account-health columns for the selection strategy: rate-limit / not-found timestamps stored as ISO-8601 TEXT, mapped to DateTime?).
Routes are defined as constants in constants.dart (routeHome, routeProfile, etc.) and registered in main.dart. Deep links from x.com URLs are parsed in utils/urls.dart into sealed ProfileUriInfo / PostUriInfo classes, then navigated in main.dart.
Strings live in lib/l10n/*.arb files. The L10n class in lib/generated/l10n.dart is auto-generated — run fvm dart run intl_utils:generate after editing ARB files. Access via L10n.of(context).someKey.
- Prefer functional patterns: immutable data, pure functions,
map/where/foldover imperative loops. Avoid mutable state outside of Store objects. - Always split responsibilities
- Avoid functions of more than 30 lines (except for some widget builders)
- NEVER insert raw strings in the code if they are displayed on the UI, always use translated strings in arb files
- Anytime when you are about to copy/paste code from somewhere, think about refactoring instead. Ask me first what to do in such cases.
- Go easy on comments. Avoid comments that are obvious or redundant, or that simply describe the code you're about to write.
/parse-api— guidance for safely parsing reverse-engineered X API responses/port-from-squawker— port a bug fix or feature from the Squawker codebase/translate— user asked anything about translation, or you tried to add/remove/edit a text that appears in the UI