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.70.4: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 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 _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'; static const String masterSkusEndpoint = '/master/skus'; static const String scanProductEndpoint = '/scan-product'; // 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); // DO mode color — orange cue used throughout for the DO Scan mode: // document_card.dart top-label, documents_tab_switcher.dart active DO tab, // camera_drawer_mode_toggle.dart DO segment decoration. Formalized as a // shared constant so all three call sites pull from one place (was an // ad-hoc Colors.orange.shade700 inline in document_card.dart). static const Color doModeColor = Color(0xFFF57C00); // == Colors.orange.shade700 // Text Colors static const Color textPrimary = Color(0xFF212121); static const Color textSecondary = Color(0xFF757575); static const Color textOnPrimary = Colors.white; // Spacing System (Margin & Padding) static const double spaceXS = 4.0; static const double spaceSmall = 8.0; static const double spaceMedium = 12.0; static const double spaceLarge = 16.0; static const double spaceXL = 24.0; static const double spaceXXL = 32.0; static const EdgeInsets paddingXS = EdgeInsets.all(spaceXS); static const EdgeInsets paddingSmall = EdgeInsets.all(spaceSmall); static const EdgeInsets paddingMedium = EdgeInsets.all(spaceMedium); static const EdgeInsets paddingLarge = EdgeInsets.all(spaceLarge); static const EdgeInsets paddingXL = EdgeInsets.all(spaceXL); static const EdgeInsets screenPadding = EdgeInsets.symmetric(horizontal: spaceXL, vertical: spaceLarge); // Border Radii static const double borderRadiusSmall = 8.0; static const double borderRadiusMedium = 12.0; static const double borderRadiusLarge = 16.0; static const double borderRadiusXL = 24.0; // 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), ), ); } }