Skip to main content

vetdoctor — the vet/clinic app

vetdoctor/ is the clinician-facing Flutter app. Vets sign in against their clinic (a specific tenant), see today's schedule at a glance and scan an owner's QR to pull that pet's chart into the clinic.

It is intentionally narrower than the web clinic console — the web app is still where a full workday happens. vetdoctor is the "pocket companion" for scanning, quick check-ins, and on-the-go status glances.

Entry flow

Screens

Auth

  • LoginScreen — email + password; links to Forgot password?. There is no register screen — clinic staff are provisioned through the web admin, and that decision is documented on AuthService:
    /// Vet/clinic side — register-owner isn't relevant here. Clinic users are
    /// provisioned by the tenant admin through the web app.
    class AuthService {
    Future<void> login(...) { /* extracts tenant from response */ }
    Future<void> forgotPassword(String email) { /* same as vetty */ }
    Future<void> logout() { /* best-effort; always clears local store */ }
    }
  • ForgotPasswordScreen — identical to vetty's: posts {email, redirect_base: ${Env.webBase}/reset-password}.

DashboardScreen (features/home/dashboard_screen.dart)

Single-page home:

  • Today sectionFutureBuilder<List<Map>> that loads GET /tenant/{id}/appointments?from=<start-of-day>&to=<start-of-next-day>. Renders a Card+ListTile per appointment with pet name and scheduled time/status.
  • Recently scanned section — purely in-memory list of pets adopted via the scan flow this session. Disappears on restart by design — the authoritative view is the web clinic console.
  • FAB — "Scan share" — pushes ScanScreen.
  • Profile — app-bar icon opens a bottom sheet with user name/email and Sign out.

Tenant scoping is resolved once, cleanly, via a small helper:

int? _tenantIdOrNull() {
final raw = widget.auth.tenant?['id'];
if (raw is int) return raw;
if (raw is num) return raw.toInt();
if (raw is String) return int.tryParse(raw);
return null;
}

Future<List<Map<String, dynamic>>> _loadToday() async {
final tid = _tenantIdOrNull();
if (tid == null) return const [];
return AppointmentService(widget.api).listToday(tenantId: tid);
}

ScanScreen (features/shares/scan_screen.dart)

A full-screen MobileScanner with a centred 240×240 "frame" guide:

  1. First barcode with a non-empty rawValue is picked up.
  2. ShareService.redeem(scanned, tenantId, userId) is called. The service normalises the scan (raw code / JSON envelope / deep-link URL) down to an 8-char code.
  3. On success the redeemed pet is returned; the dashboard surfaces a SnackBar ("Added Luna to your roster.") and inserts the pet into the "Recently scanned" list.
  4. A FAB lets the user type a code manually if the camera is struggling.

The backend validates {code, tenant_id, user_id} via RedeemPetShareRequest and reparents the pet onto the scanning clinic's tenant.

AuthStore extension

vetdoctor's AuthStore differs from vetty's in one place — it persists the tenant:

class AuthStore extends ChangeNotifier {
String? get token;
Map<String, dynamic>? get user;
Map<String, dynamic>? get tenant; // ← vetdoctor-only

Future<void> set({
required String token,
Map<String, dynamic>? user,
Map<String, dynamic>? tenant,
});
}

AuthService.login extracts the tenant envelope from the login response:

final data = res['data'] ?? res;
Map<String, dynamic>? tenant;
if (data is Map && data['tenant'] is Map) {
tenant = Map<String, dynamic>.from(data['tenant']);
}
await _store.set(token: token, user: user, tenant: tenant);

If a clinic user's account belongs to multiple tenants, the backend returns the current/primary tenant on login. Multi-tenant switching is a web-only feature today.

Services

features/
├── auth/auth_service.dart # login, forgotPassword, logout
├── shares/share_service.dart # redeem(scanned, tenantId, userId), preview(code)
└── appointments/appointment_service.dart # listToday({required tenantId})

share_service.redeem is defensive about what the scanner hands it:

String _extractCode(String raw) {
final trimmed = raw.trim();
if (trimmed.startsWith('{')) {
// JSON payload from vetty's QR: {"code":"A1B2C3D4",...}
final decoded = jsonDecode(trimmed);
if (decoded is Map && decoded['code'] is String) {
return (decoded['code'] as String).toUpperCase();
}
}
final uri = Uri.tryParse(trimmed);
if (uri != null && uri.hasScheme) {
return (uri.pathSegments.isNotEmpty
? uri.pathSegments.last
: (uri.queryParameters['code'] ?? ''))
.toUpperCase();
}
// Raw code — normalise to upper case
return trimmed.toUpperCase();
}

Theme

class VetDoctorTheme {
static ThemeData light() => ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF4F46E5)),
useMaterial3: true,
);
}

Indigo ≠ emerald so testers never confuse a vetdoctor build with a vetty build on their home screen.

Running it

cd vetdoctor
flutter pub get
flutter run \
--dart-define=VETDOCTOR_API_BASE=http://10.0.2.2:8000/api/v1 \
--dart-define=VETDOCTOR_WEB_BASE=http://localhost:3000

Tests

test/widget_test.dart is a compile smoke test that boots MaterialApp(theme: VetDoctorTheme.light()) with a placeholder body — same pattern as vetty.

What's deliberately missing

  • No appointment writes — rescheduling, cancelling, completing visits all stay on the web app. vetdoctor reads /tenant/{id}/appointments and is otherwise passive.
  • No full clinical record UI — the scan flow adopts a pet and surfaces an ack; the full chart is read in the web app.
  • No tenant picker — a single tenant is stored at login time; multi-clinic vets use the web app to switch.