Files
pfm-ocr/lib/config/app_config.dart
T
Rafhan Mazaya FathurrahmanandClaude Sonnet 5 e60ab63154 Adopt agents-settings kit, ship Product/SKU scan models, harden auth, verify OCR accuracy
Backend (app-pfm-ocr-v2/backend):
- Product/SKU scan feature complete: trained DINOv2 index (118 reference
  photos, 16 SKU classes) and YOLO classifier (83.3% top-1 val accuracy),
  fixed scripts/install-pipeline.sh (was missing ultralytics/torch), fully
  browser-verified end-to-end on /scan-pfm. Mobile m-scan-pfm page cancelled
  (Flutter app handles mobile; web UI is desktop-only for pipeline testing).
- Fixed a real data-loss bug: Save Ground Truth (scan-pfm and the DO-flow's
  manual-label) was silently writing into the pfm-web-app container's
  ephemeral filesystem instead of the host, because /sources wasn't
  bind-mounted in docker-compose.yml. Added the mount, recovered an
  orphaned entry.
- accounts.password is now bcrypt-hashed (bcryptjs, idempotent migration
  in db/init.ts) instead of plaintext; login route compares hashes.
- /api/v1/documents/* (list, PUT, upload) now enforces real 401 auth,
  matching what the Flutter client already sends. The "classic" routes
  deliberately stay open — they're dev-only web UI with no login flow and
  won't exist in production.
- OCR accuracy investigated end-to-end: real baseline is 95.10% overall
  (target met; accuracy_report.md was stale at 75.04%, now flagged). Fixed
  one genuine parser.ts bug (SO/DO field duplication in the global fallback
  regex); remaining gaps are OCR/layout-model limitations, not parser bugs.
- Adopted a standalone copy of the fhanyuh/agents-settings e/n workflow
  scoped to backend/ (AGENTS.md Part A/B split, SKILLS.md, plans/, docs/),
  independent of the root copy which now covers Flutter only.
- next-implementation.md deleted; content folded into
  backend/plans/next-enhancements.md for traceability.

Root:
- Adopted fhanyuh/agents-settings kit (AGENTS.md, SKILLS.md, plans/,
  docs/feature-list.md), scoped to the Flutter app only.
- Pending documents queue now persists to Hive (lib/core/storage) instead
  of memory-only, surviving an app kill mid-upload.

Removed backend_backup/ (stale Express/Prisma prototype, superseded by
pfm-web-app) and the completed plans/next-enhancement-plan.md checklist.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-08 11:56:32 +07:00

202 lines
7.6 KiB
Dart

import 'dart:io';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
class AppConfig {
// ─── API Configuration ────────────────────────────────────────────────────
//
// Dual-Mode Configuration, tried in this order at every app startup:
// 1. Local/LAN backend (fast, low-latency - the common case when the phone
// and backend are on the same Wi-Fi).
// 2. Public Ngrok tunnel (fallback for when the phone is off that network,
// or the LAN URL below is stale).
// Whichever responds first with a real backend (not a dead route/tunnel
// error page) wins; if neither does, LAN is used as the default so error
// messages point at the expected local backend rather than a dead tunnel.
//
// LAN / WiFi / Emulator URL (port 8000 = nginx gateway)
// - Physical Device on same Wi-Fi: use the backend machine's current LAN IP
// - Android Emulator: use 'http://10.0.2.2:8000/api/v1'
// - iOS Simulator: use 'http://localhost:8000/api/v1'
static const String _lanBaseUrl = 'http://192.168.80.84:8000/api/v1';
// Ngrok public tunnel URL (update ini setiap ngrok di-restart)
static const String _ngrokBaseUrl = 'https://unlocated-waylon-potently.ngrok-free.dev/api/v1';
// Dynamic Base URL determined at startup
static String apiBaseUrl = _lanBaseUrl;
/// Dynamic URL resolver: tries the LAN backend first, then the public
/// Ngrok tunnel, and picks whichever responds with a real backend.
static Future<void> initializeApiBaseUrl() async {
debugPrint('AppConfig: Checking local/LAN backend at $_lanBaseUrl...');
if (await _isBackendReachable(_lanBaseUrl)) {
apiBaseUrl = _lanBaseUrl;
debugPrint('AppConfig: LAN backend is ACTIVE. API Base URL: $apiBaseUrl');
return;
}
debugPrint('AppConfig: LAN backend not reachable, trying public Ngrok tunnel at $_ngrokBaseUrl...');
if (await _isBackendReachable(_ngrokBaseUrl)) {
apiBaseUrl = _ngrokBaseUrl;
debugPrint('AppConfig: Ngrok tunnel is ACTIVE. API Base URL: $apiBaseUrl');
return;
}
apiBaseUrl = _lanBaseUrl;
debugPrint('AppConfig: Neither LAN nor Ngrok reachable. Defaulting to LAN API Base URL: $apiBaseUrl');
}
/// Probes [baseUrl]/auth/login with a throwaway request to check whether a
/// real backend answers behind it - not a timed-out connection, a dead
/// tunnel, or a router/error page pretending to be a response.
static Future<bool> _isBackendReachable(String baseUrl) async {
final client = HttpClient();
client.connectionTimeout = const Duration(seconds: 2);
try {
final uri = Uri.parse('$baseUrl/auth/login');
final request = await client.postUrl(uri);
// Bypasses the Ngrok browser-warning interstitial; harmless for LAN.
request.headers.add('ngrok-skip-browser-warning', 'true');
request.headers.contentType = ContentType.json;
request.write('{}');
final response = await request.close();
final contentType = response.headers.contentType?.mimeType ?? '';
final ngrokErrorCode = response.headers.value('ngrok-error-code');
debugPrint('AppConfig: Probe $baseUrl -> status ${response.statusCode}, '
'Content-Type: $contentType, Ngrok-Error-Code: $ngrokErrorCode');
// A real backend behind this URL:
// 1. Should not return an ngrok-error-code header (dead/offline tunnel).
// 2. The response content-type should be JSON (our login API returns JSON).
// 3. Status code should not be 502, 503, or 504 (gateway/proxy errors).
return ngrokErrorCode == null &&
contentType.contains('json') &&
response.statusCode != 502 &&
response.statusCode != 503 &&
response.statusCode != 504;
} catch (e) {
debugPrint('AppConfig: Probe $baseUrl failed: $e');
return false;
} finally {
client.close();
}
}
static const String loginEndpoint = '/auth/login';
static const String uploadDocumentEndpoint = '/documents/upload';
static const String fetchDocumentsEndpoint = '/documents';
// App Constants
static const String appName = 'Prima Mart Scanner';
static const String appVersion = '1.0.0';
// Colors - Prima Fresh Mart Theme
static const Color primaryColor = Color(0xFF6EBE4A); // Prima Green
static const Color secondaryColor = Color(0xFF005DAA); // Prima Blue
static const Color accentColor = Color(0xFFFFCC00); // Prima Yellow
static const Color backgroundColor = Color(0xFFF8F9FA); // Off-white
static const Color surfaceColor = Colors.white;
static const Color errorColor = Color(0xFFD32F2F);
static const Color successColor = Color(0xFF388E3C);
// Text Colors
static const Color textPrimary = Color(0xFF212121);
static const Color textSecondary = Color(0xFF757575);
static const Color textOnPrimary = Colors.white;
// Typography Config
// Using Google Fonts - Inter for clean, modern look
static TextStyle get h1 => GoogleFonts.inter(
fontSize: 32,
fontWeight: FontWeight.bold,
color: textPrimary,
);
static TextStyle get h2 => GoogleFonts.inter(
fontSize: 24,
fontWeight: FontWeight.w600,
color: textPrimary,
);
static TextStyle get h3 => GoogleFonts.inter(
fontSize: 20,
fontWeight: FontWeight.w600,
color: textPrimary,
);
static TextStyle get bodyLarge => GoogleFonts.inter(
fontSize: 16,
fontWeight: FontWeight.normal,
color: textPrimary,
);
static TextStyle get bodyMedium => GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.normal,
color: textPrimary,
);
static TextStyle get bodySmall => GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.normal,
color: textSecondary,
);
// Theme Data Builder
static ThemeData get lightTheme {
return ThemeData(
primaryColor: primaryColor,
scaffoldBackgroundColor: backgroundColor,
colorScheme: const ColorScheme.light(
primary: primaryColor,
secondary: secondaryColor,
surface: surfaceColor,
error: errorColor,
),
appBarTheme: AppBarTheme(
backgroundColor: surfaceColor,
elevation: 0,
centerTitle: true,
iconTheme: const IconThemeData(color: textPrimary),
titleTextStyle: h2.copyWith(color: textPrimary),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: primaryColor,
foregroundColor: textOnPrimary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 24),
textStyle: bodyLarge.copyWith(fontWeight: FontWeight.w600),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: surfaceColor,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: primaryColor, width: 2),
),
labelStyle: bodyMedium.copyWith(color: textSecondary),
),
);
}
}