diff --git a/PROJECT_ARCHITECTURE.md b/PROJECT_ARCHITECTURE.md deleted file mode 100644 index 00ee9672..00000000 --- a/PROJECT_ARCHITECTURE.md +++ /dev/null @@ -1,1768 +0,0 @@ - -You are a senior Flutter developer. You will be given this entire document as your specification. -Your task: generate a complete, compilable Flutter project from scratch following every instruction below. -Do NOT skip any file. Do NOT summarize. Generate FULL, working Dart code for every file listed. -When you see placeholder names like `{{APP_NAME}}`, `{{PACKAGE_NAME}}`, `{{BASE_URL}}`, replace them with the values the user provides. If no values are provided, use the defaults in Section 1. - - -# Flutter Project Generator — Architecture Specification - -> **What this document is:** A precise, machine-readable specification for an AI to generate a greenfield Flutter project. Every file path, every class, every method signature, and every pattern is defined below with copy-ready code. Follow it top-to-bottom. - ---- - -## 1. DEFAULTS — Replace These for Your Project - -``` -APP_NAME = "My App" -DART_PACKAGE = "my_app" -ORG_IDENTIFIER = "com.example.myapp" -BASE_URL = "https://api.example.com/" -PRIMARY_COLOR = 0xFFED1C2B (Red) -FIGMA_WIDTH = 430 -FIGMA_HEIGHT = 927 -SUPPORTED_LOCALES = [en-US, ar-SA] -PRIMARY_FONT = "Poppins" -SECONDARY_FONT = "GESSTwo" (Arabic font) -``` - -When the user asks you to generate this project, first ask them for these values. If they say "use defaults," use the values above. - ---- - -## 2. COMMAND — Create the Flutter Project - -Run this first: -```bash -flutter create --org {{ORG_IDENTIFIER}} --project-name {{DART_PACKAGE}} {{DART_PACKAGE}} -cd {{DART_PACKAGE}} -``` - ---- - -## 3. FILE: `pubspec.yaml` - -Generate this file EXACTLY. Do not add or remove packages. - -```yaml -name: {{DART_PACKAGE}} -description: "{{APP_NAME}}" -publish_to: 'none' -version: 1.0.0+1 - -environment: - sdk: ">=3.6.0 <4.0.0" - -dependencies: - flutter: - sdk: flutter - flutter_localizations: - sdk: flutter - - # === Core Architecture === - provider: ^6.1.5+1 - get_it: ^8.2.0 - dartz: ^0.10.1 - equatable: ^2.0.7 - - # === Networking === - http: ^1.5.0 - connectivity_plus: ^6.1.5 - - # === Firebase === - firebase_core: any - firebase_messaging: ^15.2.10 - firebase_analytics: ^11.5.1 - firebase_crashlytics: ^4.3.8 - - # === UI === - cupertino_icons: ^1.0.8 - flutter_svg: ^2.2.0 - cached_network_image: ^3.4.1 - auto_size_text: ^3.0.0 - shimmer: ^3.0.0 - sizer: ^3.1.3 - lottie: ^3.3.1 - smooth_corner: ^1.1.1 - flutter_staggered_animations: ^1.1.1 - fl_chart: 1.0.0 - flutter_rating_bar: ^4.0.1 - - # === Localization === - easy_localization: ^3.0.8 - intl: ^0.20.2 - - # === Storage === - shared_preferences: ^2.5.3 - path_provider: ^2.0.8 - - # === Device === - permission_handler: ^12.0.1 - local_auth: ^2.3.0 - device_info_plus: ^11.5.0 - image_picker: ^1.2.0 - url_launcher: ^6.3.2 - share_plus: ^11.1.0 - - # === Notifications === - flutter_local_notifications: ^19.4.1 - timezone: ^0.10.0 - fluttertoast: ^8.2.12 - - # === Logging === - logger: ^2.6.1 - -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^5.0.0 - -flutter: - uses-material-design: true - assets: - - assets/ - - assets/json/ - - assets/fonts/ - - assets/langs/ - - assets/images/ - - assets/images/svg/ - - assets/images/png/ - - assets/animations/ - - assets/animations/lottie/ - - fonts: - - family: Poppins - fonts: - - asset: assets/fonts/poppins/Poppins-SemiBold.ttf - weight: 600 - - asset: assets/fonts/poppins/Poppins-Medium.ttf - weight: 500 - - asset: assets/fonts/poppins/Poppins-Regular.ttf - weight: 400 - - asset: assets/fonts/poppins/Poppins-Light.ttf - weight: 300 -``` - -> **Note:** If the user provides a SECONDARY_FONT, add its font family block here too. - ---- - -## 4. FILE: `analysis_options.yaml` - -```yaml -include: package:flutter_lints/flutter.yaml - -linter: - rules: - # prefer_single_quotes: true -``` - ---- - -## 5. FOLDER STRUCTURE — Create ALL These Directories - -```bash -mkdir -p lib/core/api -mkdir -p lib/core/common_models -mkdir -p lib/core/exceptions -mkdir -p lib/core/utils -mkdir -p lib/services/analytics -mkdir -p lib/features -mkdir -p lib/presentation/home/widgets -mkdir -p lib/presentation/authentication -mkdir -p lib/presentation/onboarding -mkdir -p lib/routes -mkdir -p lib/theme -mkdir -p lib/extensions -mkdir -p lib/widgets/buttons -mkdir -p lib/widgets/loader -mkdir -p lib/widgets/bottomsheet -mkdir -p lib/widgets/bottom_navigation -mkdir -p lib/widgets/shimmer -mkdir -p lib/widgets/routes -mkdir -p lib/generated -mkdir -p assets/fonts/poppins -mkdir -p assets/images/svg -mkdir -p assets/images/png -mkdir -p assets/animations/lottie -mkdir -p assets/json -mkdir -p assets/langs -mkdir -p assets/sounds -``` - ---- - -## 6. CORE LAYER — Generate Each File Exactly - -### 6.1 FILE: `lib/core/enums.dart` - -```dart -enum ViewStateEnum { hide, idle, busy, error, busyLocal, errorLocal } - -enum AppEnvironmentTypeEnum { dev, uat, preProd, qa, staging, prod } - -enum GenderTypeEnum { male, female } - -enum ChipTypeEnum { success, error, alert, info, warning, lightBg, primaryRed } - -enum LoginTypeEnum { sms, whatsapp, face, fingerprint } - -enum OTPTypeEnum { sms, whatsapp, faceIDFingerprint } -``` - -### 6.2 FILE: `lib/core/exceptions/api_failure.dart` - -```dart -import 'package:equatable/equatable.dart'; - -abstract class Failure extends Equatable implements Exception { - final String message; - const Failure(this.message); -} - -class ServerFailure extends Failure { - final String url; - const ServerFailure(super.message, {this.url = ""}); - @override - List get props => [message]; -} - -class DataParsingFailure extends Failure { - const DataParsingFailure(super.message); - @override - List get props => [message]; -} - -class ConnectivityFailure extends Failure { - const ConnectivityFailure(super.message); - @override - List get props => [message]; -} - -class UnAuthenticatedUserFailure extends Failure { - final String url; - const UnAuthenticatedUserFailure(super.message, {this.url = ""}); - @override - List get props => [message]; -} - -class AppUpdateFailure extends Failure { - const AppUpdateFailure(super.message); - @override - List get props => [message]; -} - -class StatusCodeFailure extends Failure { - const StatusCodeFailure(super.message); - @override - List get props => [message]; -} - -class UnknownFailure extends Failure { - final String url; - const UnknownFailure(super.message, {this.url = ""}); - @override - List get props => [message]; -} - -class UserIntimationFailure extends Failure { - const UserIntimationFailure(super.message); - @override - List get props => [message]; -} - -class MessageStatusFailure extends Failure { - const MessageStatusFailure(super.message); - @override - List get props => [message]; -} - -class InvalidCredentials extends Failure { - const InvalidCredentials(String? message) : super(message ?? ''); - @override - List get props => [message]; -} - -class LocalStorageFailure extends Failure { - const LocalStorageFailure(super.message); - @override - List get props => [message]; -} -``` - -### 6.3 FILE: `lib/core/common_models/generic_api_model.dart` - -```dart -class GenericApiModel { - final int? messageStatus; - final String? errorMessage; - final int? statusCode; - final T? data; - - GenericApiModel({this.messageStatus, this.errorMessage, this.statusCode, this.data}); - - factory GenericApiModel.fromJson( - Map json, - T Function(Object? json)? fromJsonT, - ) { - return GenericApiModel( - messageStatus: json['messageStatus'] as int?, - errorMessage: json['errorMessage'] as String?, - statusCode: json['statusCode'] as int?, - data: fromJsonT != null ? fromJsonT(json['data']) : json['data'] as T?, - ); - } - - Map toJson(Object Function(T value)? toJsonT) { - return { - 'messageStatus': messageStatus, - 'errorMessage': errorMessage, - 'statusCode': statusCode, - 'data': toJsonT != null && data != null ? toJsonT(data as T) : data, - }; - } -} -``` - -### 6.4 FILE: `lib/core/api_consts.dart` - -```dart -import 'package:{{DART_PACKAGE}}/core/enums.dart'; - -const int VERSION_ID = 1; -const int CHANNEL = 3; -const String IP_ADDRESS = "10.20.10.20"; -const String GENERAL_ID = "Aborkan"; -const int PATIENT_TYPE = 1; -const int PATIENT_TYPE_ID = 1; -const String SETUP_ID = "010266"; -const bool IS_DENTAL_ALLOWED_BACKEND = false; - -class ApiConsts { - static AppEnvironmentTypeEnum appEnvironmentType = AppEnvironmentTypeEnum.prod; - static String baseUrl = '{{BASE_URL}}'; - - static int appVersionID = VERSION_ID; - static int appChannelId = CHANNEL; - static String appIpAddress = IP_ADDRESS; - static String appGeneralId = GENERAL_ID; - static String sessionID = ""; - - static void setBackendURLs() { - switch (appEnvironmentType) { - case AppEnvironmentTypeEnum.prod: - baseUrl = '{{BASE_URL}}'; - break; - case AppEnvironmentTypeEnum.dev: - case AppEnvironmentTypeEnum.uat: - case AppEnvironmentTypeEnum.preProd: - case AppEnvironmentTypeEnum.qa: - case AppEnvironmentTypeEnum.staging: - baseUrl = '{{BASE_URL}}'; - break; - } - } - - // ── Add endpoint paths here as you build features ── - // static const String login = "api/auth/login"; -} -``` - -### 6.5 FILE: `lib/core/cache_consts.dart` - -```dart -class CacheConst { - static const String firstLaunch = "firstLaunch"; - static const String appAuthToken = "app_auth_token"; - static const String loggedInUserObj = "logged_in_user_obj"; - static const String pushToken = "push_token"; - static const String appLanguage = 'language'; - static const String isDarkMode = 'is_dark_mode'; - - // Add more cache keys as features are built -} -``` - -### 6.6 FILE: `lib/core/app_state.dart` - -```dart -import 'dart:io'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:{{DART_PACKAGE}}/services/navigation_service.dart'; - -class AppState { - final NavigationService navigationService; - - AppState({required this.navigationService}); - - double userLat = 0.0; - double userLong = 0.0; - - bool isArabic() { - final ctx = navigationService.navigatorKey.currentContext; - if (ctx == null) return false; - return EasyLocalization.of(ctx)?.locale.languageCode == "ar"; - } - - int getLanguageID() => isArabic() ? 1 : 2; - String? getLanguageCode() { - final ctx = navigationService.navigatorKey.currentContext; - if (ctx == null) return "en"; - return EasyLocalization.of(ctx)?.locale.languageCode; - } - int getDeviceTypeID() => Platform.isIOS ? 1 : 2; - - bool isAuthenticated = false; - String appAuthToken = ""; - String deviceToken = ""; - String voipToken = ""; - String sessionId = ""; - String deviceTypeID = ""; - - Map? authenticatedUser; - - void setAuthenticatedUser(Map? user) { - authenticatedUser = user; - isAuthenticated = user != null; - } - - void resetLocation() { - userLat = 0.0; - userLong = 0.0; - } -} -``` - -### 6.7 FILE: `lib/core/app_assets.dart` - -```dart -class AppAssets { - static const String svgBasePath = 'assets/images/svg'; - static const String pngBasePath = 'assets/images/png'; - static const String lottieBasePath = 'assets/animations/lottie'; - - // Add asset constants here as you add files. Examples: - // static const String logo = '$svgBasePath/logo.svg'; - // static const String placeholder = '$pngBasePath/placeholder.png'; -} -``` - -### 6.8 FILE: `lib/core/app_export.dart` - -```dart -export '../routes/app_routes.dart'; -export 'utils/size_utils.dart'; -``` - -### 6.9 FILE: `lib/core/utils/size_utils.dart` - -```dart -import 'package:flutter/material.dart'; - -const num figmaDesignWidth = {{FIGMA_WIDTH}}; -const num figmaDesignHeight = {{FIGMA_HEIGHT}}; - -class SizeUtils { - static late double width; - static late double height; - static late DeviceType deviceType; - - static void init(BoxConstraints constraints, Orientation orientation) { - width = constraints.maxWidth; - height = constraints.maxHeight; - deviceType = width > 600 ? DeviceType.tablet : DeviceType.phone; - } -} - -enum DeviceType { phone, tablet } - -bool get isTablet => SizeUtils.deviceType == DeviceType.tablet; - -extension ResponsiveExtension on num { - double get _width => SizeUtils.width; - double get _height => SizeUtils.height; - - double get f { - double scale = (_width < _height ? _width : _height) / figmaDesignWidth; - double clamp = isTablet ? 1.4 : 1.2; - if (scale > clamp) scale = clamp; - return this * scale; - } - - double get w => (this * _width) / figmaDesignWidth; - double get h => (this * _height) / figmaDesignHeight; - double get r => (this * _width) / figmaDesignWidth; -} -``` - -### 6.10 FILE: `lib/core/utils/loading_utils.dart` - -```dart -import 'package:flutter/material.dart'; -import 'package:{{DART_PACKAGE}}/core/dependencies.dart'; -import 'package:{{DART_PACKAGE}}/services/navigation_service.dart'; - -class LoadingUtils { - static bool isLoading = false; - static OverlayEntry? _overlayEntry; - - static void showFullScreenLoader() { - final context = getIt.get().navigatorKey.currentContext; - if (context == null || isLoading) return; - isLoading = true; - _overlayEntry = OverlayEntry( - builder: (_) => Container( - color: Colors.black38, - child: const Center(child: CircularProgressIndicator()), - ), - ); - Overlay.of(context).insert(_overlayEntry!); - } - - static void hideFullScreenLoader() { - _overlayEntry?.remove(); - _overlayEntry = null; - isLoading = false; - } -} -``` - -### 6.11 FILE: `lib/core/api/api_client.dart` - -```dart -import 'dart:convert'; -import 'package:flutter/foundation.dart'; -import 'package:http/http.dart' as http; -import 'package:{{DART_PACKAGE}}/core/api_consts.dart'; -import 'package:{{DART_PACKAGE}}/core/app_state.dart'; -import 'package:{{DART_PACKAGE}}/core/exceptions/api_failure.dart'; - -abstract class ApiClient { - Future post( - String endPoint, { - required Map body, - required Function(dynamic response, int statusCode, {int? messageStatus, String? errorMessage}) onSuccess, - required Function(String error, int statusCode, {int? messageStatus, Failure? failureType}) onFailure, - bool isExternal = false, - Map? apiHeaders, - }); - - Future get( - String endPoint, { - required Function(dynamic response, int statusCode) onSuccess, - required Function(String error, int statusCode) onFailure, - Map? queryParams, - Map? apiHeaders, - bool isExternal = false, - }); -} - -class ApiClientImp implements ApiClient { - final AppState _appState; - - ApiClientImp({required AppState appState}) : _appState = appState; - - Map _defaultHeaders() => {'Content-Type': 'application/json', 'Accept': 'application/json'}; - - void _injectCommonFields(Map body) { - body['VersionID'] = ApiConsts.appVersionID.toString(); - body['Channel'] = ApiConsts.appChannelId.toString(); - body['IPAdress'] = ApiConsts.appIpAddress; - body['generalid'] = ApiConsts.appGeneralId; - body['LanguageID'] = _appState.getLanguageID().toString(); - body['Latitude'] = _appState.userLat.toString(); - body['Longitude'] = _appState.userLong.toString(); - body['DeviceTypeID'] = _appState.deviceTypeID; - if (_appState.appAuthToken.isNotEmpty) { - body[_appState.isAuthenticated ? 'TokenID' : 'LogInTokenID'] = _appState.appAuthToken; - } - } - - @override - Future post( - String endPoint, { - required Map body, - required Function(dynamic response, int statusCode, {int? messageStatus, String? errorMessage}) onSuccess, - required Function(String error, int statusCode, {int? messageStatus, Failure? failureType}) onFailure, - bool isExternal = false, - Map? apiHeaders, - }) async { - final url = isExternal ? endPoint : ApiConsts.baseUrl + endPoint; - final headers = apiHeaders ?? _defaultHeaders(); - if (!isExternal) _injectCommonFields(body); - - try { - final response = await http.post(Uri.parse(url), headers: headers, body: jsonEncode(body)); - final statusCode = response.statusCode; - - if (statusCode == 401) { - onFailure('Unauthenticated', statusCode, failureType: UnAuthenticatedUserFailure('Session expired', url: url)); - return; - } - if (statusCode == 426) { - onFailure('App update required', statusCode, failureType: const AppUpdateFailure('Please update the app')); - return; - } - - if (statusCode >= 200 && statusCode < 300) { - final decoded = jsonDecode(response.body); - final int? messageStatus = decoded['MessageStatus']; - final String? errorMessage = decoded['ErrorEndUserMessage'] ?? decoded['ErrorMessage']; - - if (messageStatus == 1 || messageStatus == null) { - onSuccess(decoded, statusCode, messageStatus: messageStatus, errorMessage: errorMessage); - } else { - onFailure(errorMessage ?? 'Request failed', statusCode, - messageStatus: messageStatus, - failureType: MessageStatusFailure(errorMessage ?? 'Request failed with status $messageStatus')); - } - } else { - onFailure('Server error: $statusCode', statusCode, failureType: ServerFailure('Server error: $statusCode', url: url)); - } - } catch (e) { - if (kDebugMode) print('API POST error ($url): $e'); - onFailure(e.toString(), 0, failureType: UnknownFailure(e.toString(), url: url)); - } - } - - @override - Future get( - String endPoint, { - required Function(dynamic response, int statusCode) onSuccess, - required Function(String error, int statusCode) onFailure, - Map? queryParams, - Map? apiHeaders, - bool isExternal = false, - }) async { - final baseUrl = isExternal ? endPoint : ApiConsts.baseUrl + endPoint; - final uri = Uri.parse(baseUrl).replace(queryParameters: queryParams?.map((k, v) => MapEntry(k, v.toString()))); - final headers = apiHeaders ?? _defaultHeaders(); - - try { - final response = await http.get(uri, headers: headers); - if (response.statusCode >= 200 && response.statusCode < 300) { - onSuccess(jsonDecode(response.body), response.statusCode); - } else { - onFailure('GET failed: ${response.statusCode}', response.statusCode); - } - } catch (e) { - onFailure(e.toString(), 0); - } - } -} -``` - ---- - -## 7. SERVICES LAYER — Generate Each File Exactly - -### 7.1 FILE: `lib/services/logger_service.dart` - -```dart -import 'package:logger/logger.dart'; - -abstract class LoggerService { - void logError(String message); - void logInfo(String message); -} - -class LoggerServiceImp implements LoggerService { - final Logger logger; - LoggerServiceImp({required this.logger}); - - @override - void logError(String message) => logger.e(message); - @override - void logInfo(String message) => logger.d(message); -} -``` - -### 7.2 FILE: `lib/services/navigation_service.dart` - -```dart -import 'package:flutter/material.dart'; - -class NavigationService { - final GlobalKey navigatorKey = GlobalKey(); - - BuildContext? get context => navigatorKey.currentContext; - - Future push(Route route) => navigatorKey.currentState!.push(route); - - Future pushAndRemoveUntil(Route route, RoutePredicate predicate) => - navigatorKey.currentState!.pushAndRemoveUntil(route, predicate); - - void pop([T? result]) => navigatorKey.currentState!.pop(result); - - void popUntilNamed(String routeName) => - navigatorKey.currentState?.popUntil(ModalRoute.withName(routeName)); - - void replaceAllRoutesAndNavigateTo(String routeName) { - navigatorKey.currentState?.pushNamedAndRemoveUntil(routeName, (route) => false); - } - - void pushAndReplace(String routeName) => - navigatorKey.currentState?.pushReplacementNamed(routeName); - - void pushNamed(String routeName, {Object? arguments}) => - navigatorKey.currentState?.pushNamed(routeName, arguments: arguments); - - Future pushPage({required Widget page, bool fullscreenDialog = false}) => - navigatorKey.currentState!.push( - MaterialPageRoute(builder: (_) => page, fullscreenDialog: fullscreenDialog), - ); -} -``` - -### 7.3 FILE: `lib/services/cache_service.dart` - -```dart -import 'dart:convert'; -import 'package:{{DART_PACKAGE}}/services/logger_service.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -abstract class CacheService { - Future saveString({required String key, required String value}); - Future saveInt({required String key, required int value}); - Future saveBool({required String key, required bool value}); - String? getString({required String key}); - int? getInt({required String key}); - bool? getBool({required String key}); - Future getObject({required String key}); - Future saveObject({required String key, required dynamic value}); - Future remove({required String key}); - Future clear(); -} - -class CacheServiceImp implements CacheService { - final SharedPreferences sharedPreferences; - final LoggerService loggerService; - - CacheServiceImp({required this.sharedPreferences, required this.loggerService}); - - @override - Future saveString({required String key, required String value}) async => await sharedPreferences.setString(key, value); - @override - Future saveInt({required String key, required int value}) async => await sharedPreferences.setInt(key, value); - @override - Future saveBool({required String key, required bool value}) async => await sharedPreferences.setBool(key, value); - @override - String? getString({required String key}) => sharedPreferences.getString(key); - @override - int? getInt({required String key}) => sharedPreferences.getInt(key); - @override - bool? getBool({required String key}) => sharedPreferences.getBool(key); - - @override - Future getObject({required String key}) async { - try { - await sharedPreferences.reload(); - final s = sharedPreferences.getString(key); - return s == null ? null : json.decode(s); - } catch (e) { - loggerService.logError(e.toString()); - return null; - } - } - - @override - Future saveObject({required String key, required dynamic value}) async => - await sharedPreferences.setString(key, json.encode(value)); - @override - Future remove({required String key}) async => await sharedPreferences.remove(key); - @override - Future clear() async => await sharedPreferences.clear(); -} -``` - -### 7.4 FILE: `lib/services/dialog_service.dart` - -```dart -import 'package:flutter/material.dart'; -import 'package:{{DART_PACKAGE}}/services/navigation_service.dart'; -import 'package:{{DART_PACKAGE}}/theme/colors.dart'; - -abstract class DialogService { - Future showErrorBottomSheet({String title, required String message, Function()? onOkPressed}); - Future showConfirmBottomSheet({required String message, required Function() onOkPressed, Function()? onCancelPressed}); -} - -class DialogServiceImp implements DialogService { - final NavigationService navigationService; - bool _isSheetShowing = false; - - DialogServiceImp({required this.navigationService}); - - @override - Future showErrorBottomSheet({String title = "", required String message, Function()? onOkPressed}) async { - if (_isSheetShowing) return; - final context = navigationService.navigatorKey.currentContext; - if (context == null) return; - _isSheetShowing = true; - await showModalBottomSheet( - context: context, - shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))), - builder: (_) => Padding( - padding: const EdgeInsets.all(24), - child: Column(mainAxisSize: MainAxisSize.min, children: [ - if (title.isNotEmpty) ...[Text(title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)), const SizedBox(height: 12)], - Text(message, textAlign: TextAlign.center), - const SizedBox(height: 24), - SizedBox( - width: double.infinity, - child: ElevatedButton( - style: ElevatedButton.styleFrom(backgroundColor: AppColors.primaryRedColor), - onPressed: () { Navigator.pop(context); onOkPressed?.call(); }, - child: const Text('OK', style: TextStyle(color: Colors.white)), - ), - ), - ]), - ), - ); - _isSheetShowing = false; - } - - @override - Future showConfirmBottomSheet({required String message, required Function() onOkPressed, Function()? onCancelPressed}) async { - final context = navigationService.navigatorKey.currentContext; - if (context == null) return; - await showModalBottomSheet( - context: context, - shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))), - builder: (_) => Padding( - padding: const EdgeInsets.all(24), - child: Column(mainAxisSize: MainAxisSize.min, children: [ - Text(message, textAlign: TextAlign.center), - const SizedBox(height: 24), - Row(children: [ - Expanded(child: OutlinedButton(onPressed: () { Navigator.pop(context); onCancelPressed?.call(); }, child: const Text('Cancel'))), - const SizedBox(width: 12), - Expanded(child: ElevatedButton( - style: ElevatedButton.styleFrom(backgroundColor: AppColors.primaryRedColor), - onPressed: () { Navigator.pop(context); onOkPressed(); }, - child: const Text('OK', style: TextStyle(color: Colors.white)), - )), - ]), - ]), - ), - ); - } -} -``` - -### 7.5 FILE: `lib/services/error_handler_service.dart` - -```dart -import 'package:{{DART_PACKAGE}}/core/exceptions/api_failure.dart'; -import 'package:{{DART_PACKAGE}}/core/utils/loading_utils.dart'; -import 'package:{{DART_PACKAGE}}/services/dialog_service.dart'; -import 'package:{{DART_PACKAGE}}/services/logger_service.dart'; -import 'package:{{DART_PACKAGE}}/services/navigation_service.dart'; -import 'package:{{DART_PACKAGE}}/routes/app_routes.dart'; - -abstract class ErrorHandlerService { - Future handleError({required Failure failure, Function()? onOkPressed, Function(Failure)? onUnHandledFailure, Function(Failure)? onMessageStatusFailure}); -} - -class ErrorHandlerServiceImp implements ErrorHandlerService { - final DialogService dialogService; - final LoggerService loggerService; - final NavigationService navigationService; - - ErrorHandlerServiceImp({required this.dialogService, required this.loggerService, required this.navigationService}); - - @override - Future handleError({required Failure failure, Function()? onOkPressed, Function(Failure)? onUnHandledFailure, Function(Failure)? onMessageStatusFailure}) async { - if (LoadingUtils.isLoading) LoadingUtils.hideFullScreenLoader(); - - if (failure is ServerFailure) { - loggerService.logError("Server: ${failure.message}"); - await _show(failure); - } else if (failure is ConnectivityFailure) { - loggerService.logError("Connectivity: ${failure.message}"); - await _show(failure, title: "No Internet"); - } else if (failure is UnAuthenticatedUserFailure) { - loggerService.logError("Unauth: ${failure.message}"); - await _show(failure, title: "Session Expired", onOk: () => navigationService.replaceAllRoutesAndNavigateTo(AppRoutes.landingScreen)); - } else if (failure is AppUpdateFailure) { - await _show(failure, title: "Update Required"); - } else if (failure is DataParsingFailure) { - loggerService.logError("Parse: ${failure.message}"); - await _show(failure, title: "Data Error"); - } else if (failure is UserIntimationFailure) { - onUnHandledFailure != null ? onUnHandledFailure(failure) : await _show(failure, onOk: onOkPressed); - } else if (failure is MessageStatusFailure) { - onMessageStatusFailure != null ? onMessageStatusFailure(failure) : await _show(failure, onOk: onOkPressed); - } else { - loggerService.logError("Unhandled: $failure"); - await _show(failure, onOk: onOkPressed); - } - } - - Future _show(Failure f, {String? title, Function()? onOk}) async => - await dialogService.showErrorBottomSheet(title: title ?? "", message: f.message, onOkPressed: onOk); -} -``` - -### 7.6 FILE: `lib/services/firebase_service.dart` - -```dart -import 'package:firebase_messaging/firebase_messaging.dart'; -import 'package:{{DART_PACKAGE}}/core/app_state.dart'; -import 'package:{{DART_PACKAGE}}/services/logger_service.dart'; - -abstract class FirebaseService { - Future getDeviceToken(); -} - -class FirebaseServiceImpl implements FirebaseService { - final FirebaseMessaging firebaseMessaging; - final LoggerService loggerService; - final AppState appState; - - FirebaseServiceImpl({required this.firebaseMessaging, required this.loggerService, required this.appState}); - - @override - Future getDeviceToken() async { - try { - final token = await firebaseMessaging.getToken(); - appState.deviceToken = token ?? ""; - return appState.deviceToken; - } catch (e) { - loggerService.logError(e.toString()); - return ""; - } - } -} -``` - -### 7.7 FILE: `lib/services/notification_service.dart` - -```dart -import 'package:flutter_local_notifications/flutter_local_notifications.dart'; -import 'package:{{DART_PACKAGE}}/services/logger_service.dart'; -import 'package:timezone/data/latest_all.dart' as tz; - -abstract class NotificationService { - Future initialize({Function(String payload)? onNotificationClick}); - Future showNotification({required String title, required String body, String? payload}); - Future cancelAllNotifications(); -} - -class NotificationServiceImp implements NotificationService { - final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin; - final LoggerService loggerService; - - NotificationServiceImp({required this.flutterLocalNotificationsPlugin, required this.loggerService}); - - @override - Future initialize({Function(String payload)? onNotificationClick}) async { - tz.initializeTimeZones(); - const android = AndroidInitializationSettings('app_icon'); - const ios = DarwinInitializationSettings(requestAlertPermission: true, requestBadgePermission: true, requestSoundPermission: true); - await flutterLocalNotificationsPlugin.initialize( - const InitializationSettings(android: android, iOS: ios), - onDidReceiveNotificationResponse: (r) => onNotificationClick?.call(r.payload ?? ''), - ); - } - - @override - Future showNotification({required String title, required String body, String? payload}) async { - const details = NotificationDetails( - android: AndroidNotificationDetails('general', 'General', importance: Importance.high, priority: Priority.high), - iOS: DarwinNotificationDetails(), - ); - await flutterLocalNotificationsPlugin.show(DateTime.now().millisecondsSinceEpoch ~/ 1000, title, body, details, payload: payload); - } - - @override - Future cancelAllNotifications() async => await flutterLocalNotificationsPlugin.cancelAll(); -} -``` - -### 7.8 FILE: `lib/services/analytics/analytics_service.dart` - -```dart -import 'package:firebase_analytics/firebase_analytics.dart'; - -class GAnalytics { - final _analytics = FirebaseAnalytics.instance; - - Future logEvent(String name, {Map? parameters}) async { - try { - await _analytics.logEvent(name: name.trim().toLowerCase(), parameters: parameters?.map((k, v) => MapEntry(k, v as Object))); - } catch (_) {} - } - - Future setUser({String? userId, String? language}) async { - if (userId != null) _analytics.setUserProperty(name: 'userid', value: userId); - if (language != null) _analytics.setUserProperty(name: 'user_language', value: language); - } -} -``` - ---- - -## 8. THEME LAYER - -### 8.1 FILE: `lib/theme/colors.dart` - -```dart -import 'package:flutter/material.dart'; - -class AppColors { - static bool isDarkMode = false; - static const transparent = Colors.transparent; - - static Color get scaffoldBgColor => isDarkMode ? dark.scaffoldBgColor : const Color(0xFFF8F8F8); - static Color get whiteColor => isDarkMode ? dark.whiteColor : const Color(0xFFFFFFFF); - static Color get primaryRedColor => isDarkMode ? dark.primaryRedColor : const Color({{PRIMARY_COLOR}}); - static Color get secondaryLightRedColor => isDarkMode ? dark.secondaryLightRedColor : const Color(0xFFFEE9EA); - static const Color successColor = Color(0xFF18C273); - static Color get textColor => isDarkMode ? dark.textColor : const Color(0xFF2E3039); - static Color get iconColor => isDarkMode ? dark.iconColor : const Color(0xFF2E3039); - static Color get greyColor => isDarkMode ? dark.greyColor : const Color(0xFFEFEFF0); - static Color get textColorLight => isDarkMode ? dark.textColorLight : const Color(0xFF5E5E5E); - static Color get dividerColor => isDarkMode ? dark.dividerColor : const Color(0x40D2D2D2); - static Color get borderGrayColor => isDarkMode ? dark.borderGrayColor : const Color(0x332E3039); - static Color get greyTextColor => isDarkMode ? dark.greyTextColor : const Color(0xFF8F9AA3); - static Color get inputLabelTextColor => isDarkMode ? dark.inputLabelTextColor : const Color(0xFF898A8D); - static Color get errorColor => isDarkMode ? dark.errorColor : const Color(0xFFED1C2B); - static Color get warningColor => isDarkMode ? dark.warningColor : const Color(0xFFFFCC00); - static Color get infoColor => isDarkMode ? dark.infoColor : const Color(0xFF0B85F7); - static Color get shimmerBaseColor => isDarkMode ? dark.shimmerBaseColor : const Color(0xFFE0E0E0); - static Color get shimmerHighlightColor => isDarkMode ? dark.shimmerHighlightColor : const Color(0xFFF5F5F5); - - static Color get blackColor => textColor; - static Color get bgScaffoldColor => scaffoldBgColor; - - static const AppColorsDark dark = AppColorsDark(); -} - -class AppColorsDark { - const AppColorsDark(); - Color get scaffoldBgColor => const Color(0xFF191919); - Color get whiteColor => const Color(0xFF1E1E1E); - Color get primaryRedColor => const Color(0xFFDE5C5D); - Color get secondaryLightRedColor => const Color(0xFF3A1015); - Color get textColor => const Color(0xFFFFFFFF); - Color get iconColor => const Color(0xFFFFFFFF); - Color get greyColor => const Color(0xFF2C2C2E); - Color get textColorLight => const Color(0xFFB0B0B0); - Color get dividerColor => const Color(0x33FFFFFF); - Color get borderGrayColor => const Color(0x55ECECEC); - Color get greyTextColor => const Color(0xFF8F9AA3); - Color get inputLabelTextColor => const Color(0xFF9E9E9E); - Color get errorColor => const Color(0xFFD63D48); - Color get warningColor => const Color(0xFFFFCC00); - Color get infoColor => const Color(0xFF4DA6FF); - Color get shimmerBaseColor => const Color(0xFF2C2C2E); - Color get shimmerHighlightColor => const Color(0xFF3A3A3C); -} -``` - -### 8.2 FILE: `lib/theme/app_theme.dart` - -```dart -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:{{DART_PACKAGE}}/theme/colors.dart'; - -class AppTheme { - static ThemeData getTheme(bool isArabic) => ThemeData( - fontFamily: isArabic ? '{{SECONDARY_FONT}}' : '{{PRIMARY_FONT}}', - primarySwatch: Colors.red, - brightness: Brightness.light, - visualDensity: VisualDensity.adaptivePlatformDensity, - pageTransitionsTheme: const PageTransitionsTheme(builders: { - TargetPlatform.android: ZoomPageTransitionsBuilder(), - TargetPlatform.iOS: CupertinoPageTransitionsBuilder(), - }), - canvasColor: Colors.white, - splashColor: Colors.transparent, - appBarTheme: const AppBarTheme(elevation: 0, systemOverlayStyle: SystemUiOverlayStyle.light, surfaceTintColor: Colors.transparent), - ); - - static ThemeData getDarkTheme(bool isArabic) => ThemeData( - fontFamily: isArabic ? '{{SECONDARY_FONT}}' : '{{PRIMARY_FONT}}', - primarySwatch: Colors.red, - brightness: Brightness.dark, - visualDensity: VisualDensity.adaptivePlatformDensity, - pageTransitionsTheme: const PageTransitionsTheme(builders: { - TargetPlatform.android: ZoomPageTransitionsBuilder(), - TargetPlatform.iOS: CupertinoPageTransitionsBuilder(), - }), - canvasColor: AppColors.dark.scaffoldBgColor, - scaffoldBackgroundColor: AppColors.dark.scaffoldBgColor, - splashColor: Colors.transparent, - appBarTheme: AppBarTheme(color: AppColors.dark.scaffoldBgColor, elevation: 0, systemOverlayStyle: SystemUiOverlayStyle.light, surfaceTintColor: Colors.transparent), - ); -} -``` - ---- - -## 9. EXTENSIONS - -### 9.1 FILE: `lib/extensions/int_extensions.dart` - -```dart -import 'package:flutter/material.dart'; - -extension IntExtensions on int { - Widget get height => SizedBox(height: toDouble()); - Widget get width => SizedBox(width: toDouble()); - Widget get divider => Divider(height: toDouble(), thickness: toDouble(), color: const Color(0x30D2D2D2)); -} -``` - -### 9.2 FILE: `lib/extensions/string_extensions.dart` - -```dart -import 'package:flutter/material.dart'; -import 'package:{{DART_PACKAGE}}/core/utils/size_utils.dart'; -import 'package:{{DART_PACKAGE}}/theme/colors.dart'; - -extension CapExtension on String { - String get inCaps => isEmpty ? '' : '${this[0].toUpperCase()}${substring(1)}'; - String get capitalizeFirstOfEach => trim().isEmpty ? '' : trim().toLowerCase().split(' ').map((s) => s.inCaps).join(' '); -} - -extension StringToWidget on String { - Widget _text(double size, {Color? color, FontWeight? fontWeight, bool isBold = false, bool isCenter = false, bool isUnderLine = false, int? maxLines, TextOverflow? overflow, double letterSpacing = 0}) { - return Text(this, - textAlign: isCenter ? TextAlign.center : null, - maxLines: maxLines, - overflow: overflow, - style: TextStyle(fontSize: size.f, fontWeight: fontWeight ?? (isBold ? FontWeight.bold : FontWeight.normal), color: color ?? AppColors.blackColor, letterSpacing: letterSpacing, decoration: isUnderLine ? TextDecoration.underline : null, decorationColor: color ?? AppColors.blackColor), - ); - } - - Widget toText8({Color? color, FontWeight? fontWeight, bool isBold = false, int? maxLines, TextOverflow? overflow}) => _text(8, color: color, fontWeight: fontWeight, isBold: isBold, maxLines: maxLines, overflow: overflow); - Widget toText10({Color? color, FontWeight? fontWeight, bool isBold = false, bool isCenter = false, int? maxLines, TextOverflow? overflow}) => _text(10, color: color, fontWeight: fontWeight, isBold: isBold, isCenter: isCenter, maxLines: maxLines, overflow: overflow); - Widget toText11({Color? color, FontWeight? fontWeight, bool isBold = false, bool isCenter = false, int? maxLines}) => _text(11, color: color, fontWeight: fontWeight, isBold: isBold, isCenter: isCenter, maxLines: maxLines); - Widget toText12({Color? color, FontWeight? fontWeight, bool isBold = false, bool isCenter = false, int? maxLines, TextOverflow? overflow}) => _text(12, color: color, fontWeight: fontWeight, isBold: isBold, isCenter: isCenter, maxLines: maxLines, overflow: overflow); - Widget toText13({Color? color, FontWeight? fontWeight, bool isBold = false, bool isCenter = false, int? maxLines, TextOverflow? overflow}) => _text(13, color: color, fontWeight: fontWeight, isBold: isBold, isCenter: isCenter, maxLines: maxLines, overflow: overflow); - Widget toText14({Color? color, FontWeight? fontWeight, bool isBold = false, bool isCenter = false, int? maxLines, TextOverflow? overflow}) => _text(14, color: color, fontWeight: fontWeight, isBold: isBold, isCenter: isCenter, maxLines: maxLines, overflow: overflow); - Widget toText16({Color? color, FontWeight? fontWeight, bool isBold = false, bool isCenter = false, int? maxLines, TextOverflow? overflow}) => _text(16, color: color, fontWeight: fontWeight, isBold: isBold, isCenter: isCenter, maxLines: maxLines, overflow: overflow); - Widget toText18({Color? color, FontWeight? fontWeight, bool isBold = false, bool isCenter = false, int? maxLines}) => _text(18, color: color, fontWeight: fontWeight, isBold: isBold, isCenter: isCenter, maxLines: maxLines); - Widget toText20({Color? color, FontWeight? fontWeight, bool isBold = false, bool isCenter = false}) => _text(20, color: color, fontWeight: fontWeight, isBold: isBold, isCenter: isCenter); - Widget toText24({Color? color, FontWeight? fontWeight, bool isBold = false, bool isCenter = false}) => _text(24, color: color, fontWeight: fontWeight, isBold: isBold, isCenter: isCenter); - Widget toText30({Color? color, FontWeight? fontWeight, bool isBold = false, bool isCenter = false}) => _text(30, color: color, fontWeight: fontWeight, isBold: isBold, isCenter: isCenter); -} -``` - -### 9.3 FILE: `lib/extensions/widget_extensions.dart` - -```dart -import 'package:flutter/material.dart'; -import 'package:shimmer/shimmer.dart'; -import 'package:{{DART_PACKAGE}}/theme/colors.dart'; - -extension WidgetExtensions on Widget { - Widget onPress(VoidCallback onTap) => InkWell(onTap: onTap, child: this); - Widget get expanded => Expanded(child: this); - Widget get center => Center(child: this); - Widget circle(double v) => ClipRRect(borderRadius: BorderRadius.circular(v), child: this); - Widget paddingAll(double v) => Padding(padding: EdgeInsets.all(v), child: this); - Widget paddingSymmetrical(double h, double v) => Padding(padding: EdgeInsets.symmetric(horizontal: h, vertical: v), child: this); - Widget paddingOnly({double left = 0, double right = 0, double top = 0, double bottom = 0}) => Padding(padding: EdgeInsetsDirectional.only(start: left, end: right, top: top, bottom: bottom), child: this); - Widget toShimmer({bool isShow = true}) => isShow ? Shimmer.fromColors(baseColor: AppColors.shimmerBaseColor, highlightColor: AppColors.shimmerHighlightColor, child: this) : this; - Widget objectContainerView({double radius = 15}) => Container( - padding: const EdgeInsets.all(15), - decoration: BoxDecoration(color: AppColors.whiteColor, borderRadius: BorderRadius.circular(radius), boxShadow: [BoxShadow(color: const Color(0xff000000).withOpacity(.15), blurRadius: 26, offset: const Offset(0, -3))]), - child: this, - ); -} -``` - -### 9.4 FILE: `lib/extensions/context_extensions.dart` - -```dart -import 'package:flutter/material.dart'; - -extension ContextUtils on BuildContext { - double get screenHeight => MediaQuery.of(this).size.height; - double get screenWidth => MediaQuery.of(this).size.width; - ThemeData get theme => Theme.of(this); - TextTheme get textTheme => theme.textTheme; -} - -extension ShowBottomSheetExt on BuildContext { - Future showSheet({required Widget child, bool isScrollControlled = true, bool isDismissible = true}) { - return showModalBottomSheet(context: this, isScrollControlled: isScrollControlled, isDismissible: isDismissible, backgroundColor: Colors.transparent, builder: (_) => child); - } -} -``` - -### 9.5 FILE: `lib/extensions/route_extensions.dart` - -```dart -import 'package:flutter/material.dart'; - -extension NavigationExtensions on BuildContext { - void navigateWithName(String routeName, {Object? arguments}) => Navigator.pushNamed(this, routeName, arguments: arguments); - Future navigateReplaceWithName(String routeName, {Object? arguments}) async => await Navigator.pushReplacementNamed(this, routeName, arguments: arguments); - void navigateAndRemoveAll(String routeName) => Navigator.pushNamedAndRemoveUntil(this, routeName, (route) => false); - void pop() => Navigator.of(this).pop(); - void pushTo(Widget page) => Navigator.push(this, MaterialPageRoute(builder: (_) => page)); -} -``` - ---- - -## 10. WIDGETS — Starter Widgets - -### 10.1 FILE: `lib/widgets/buttons/custom_button.dart` - -```dart -import 'package:flutter/material.dart'; -import 'package:{{DART_PACKAGE}}/core/utils/size_utils.dart'; -import 'package:{{DART_PACKAGE}}/theme/colors.dart'; - -class CustomButton extends StatelessWidget { - final String label; - final VoidCallback? onPressed; - final Color? color; - final Color? textColor; - final double? width; - final bool isOutlined; - final bool isLoading; - - const CustomButton({super.key, required this.label, this.onPressed, this.color, this.textColor, this.width, this.isOutlined = false, this.isLoading = false}); - - @override - Widget build(BuildContext context) { - final bg = color ?? AppColors.primaryRedColor; - return SizedBox( - width: width ?? double.infinity, - height: 48.h, - child: isOutlined - ? OutlinedButton(onPressed: isLoading ? null : onPressed, style: OutlinedButton.styleFrom(side: BorderSide(color: bg), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.r))), child: _child(bg)) - : ElevatedButton(onPressed: isLoading ? null : onPressed, style: ElevatedButton.styleFrom(backgroundColor: bg, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.r))), child: _child(textColor ?? Colors.white)), - ); - } - - Widget _child(Color c) => isLoading ? SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2, color: c)) : Text(label, style: TextStyle(fontSize: 14.f, fontWeight: FontWeight.w600, color: c)); -} -``` - -### 10.2 FILE: `lib/widgets/routes/fade_page.dart` - -```dart -import 'package:flutter/material.dart'; - -class FadePage extends PageRouteBuilder { - final Widget page; - FadePage({required this.page}) : super( - pageBuilder: (_, __, ___) => page, - transitionsBuilder: (_, a, __, child) => FadeTransition(opacity: a, child: child), - transitionDuration: const Duration(milliseconds: 300), - ); -} -``` - ---- - -## 11. DEPENDENCY INJECTION — `lib/core/dependencies.dart` - -```dart -import 'package:firebase_messaging/firebase_messaging.dart'; -import 'package:flutter_local_notifications/flutter_local_notifications.dart'; -import 'package:get_it/get_it.dart'; -import 'package:logger/logger.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -import 'package:{{DART_PACKAGE}}/core/api/api_client.dart'; -import 'package:{{DART_PACKAGE}}/core/app_state.dart'; -import 'package:{{DART_PACKAGE}}/services/analytics/analytics_service.dart'; -import 'package:{{DART_PACKAGE}}/services/cache_service.dart'; -import 'package:{{DART_PACKAGE}}/services/dialog_service.dart'; -import 'package:{{DART_PACKAGE}}/services/error_handler_service.dart'; -import 'package:{{DART_PACKAGE}}/services/firebase_service.dart'; -import 'package:{{DART_PACKAGE}}/services/logger_service.dart'; -import 'package:{{DART_PACKAGE}}/services/navigation_service.dart'; -import 'package:{{DART_PACKAGE}}/services/notification_service.dart'; - -final GetIt getIt = GetIt.instance; - -class AppDependencies { - static Future addDependencies() async { - final logger = Logger(printer: PrettyPrinter(methodCount: 2, errorMethodCount: 5, lineLength: 1000, colors: true, printEmojis: true)); - - getIt.registerLazySingleton(() => LoggerServiceImp(logger: logger)); - getIt.registerLazySingleton(() => NavigationService()); - getIt.registerLazySingleton(() => AppState(navigationService: getIt())); - getIt.registerLazySingleton(() => GAnalytics()); - getIt.registerLazySingleton(() => DialogServiceImp(navigationService: getIt())); - getIt.registerLazySingleton(() => ErrorHandlerServiceImp(dialogService: getIt(), loggerService: getIt(), navigationService: getIt())); - - final sp = await SharedPreferences.getInstance(); - getIt.registerLazySingleton(() => CacheServiceImp(sharedPreferences: sp, loggerService: getIt())); - getIt.registerLazySingleton(() => ApiClientImp(appState: getIt())); - getIt.registerLazySingleton(() => FirebaseServiceImpl(firebaseMessaging: FirebaseMessaging.instance, loggerService: getIt(), appState: getIt())); - - final flnp = FlutterLocalNotificationsPlugin(); - getIt.registerLazySingleton(() => NotificationServiceImp(flutterLocalNotificationsPlugin: flnp, loggerService: getIt())); - - // ═══ Register feature repos & ViewModels below as you add them ═══ - } -} -``` - ---- - -## 12. ROUTING — `lib/routes/app_routes.dart` - -```dart -import 'package:flutter/material.dart'; -import 'package:{{DART_PACKAGE}}/presentation/home/navigation_screen.dart'; -import 'package:{{DART_PACKAGE}}/splash_page.dart'; - -class AppRoutes { - static const String initialRoute = '/'; - static const String landingScreen = '/landingScreen'; - - // Add route names as you build features: - // static const String loginScreen = '/loginScreen'; - - static Map get routes => { - initialRoute: (_) => const SplashPage(), - landingScreen: (_) => const LandingNavigation(), - }; -} -``` - ---- - -## 13. LOCALIZATION FILES - -### FILE: `assets/langs/en-US.json` - -```json -{ - "appName": "{{APP_NAME}}", - "home": "Home", - "services": "Services", - "medicalFile": "Medical File", - "bookAppointment": "Book Appointment", - "login": "Login", - "register": "Register", - "cancel": "Cancel", - "confirm": "Confirm", - "done": "Done", - "ok": "OK", - "loading": "Loading...", - "noDataAvailable": "No data available", - "noInternetConnection": "No internet connection", - "settings": "Settings", - "language": "Language", - "darkMode": "Dark Mode", - "logout": "Logout", - "search": "Search" -} -``` - -### FILE: `assets/langs/ar-SA.json` - -```json -{ - "appName": "{{APP_NAME}}", - "home": "الرئيسية", - "services": "الخدمات", - "medicalFile": "الملف الطبي", - "bookAppointment": "حجز موعد", - "login": "تسجيل الدخول", - "register": "التسجيل", - "cancel": "إلغاء", - "confirm": "تأكيد", - "done": "تم", - "ok": "حسنا", - "loading": "جاري التحميل...", - "noDataAvailable": "لا توجد بيانات", - "noInternetConnection": "لا يوجد اتصال بالإنترنت", - "settings": "الإعدادات", - "language": "اللغة", - "darkMode": "الوضع الداكن", - "logout": "تسجيل الخروج", - "search": "بحث" -} -``` - ---- - -## 14. PRESENTATION — Starter Screens - -### FILE: `lib/splash_page.dart` - -```dart -import 'dart:async'; -import 'package:flutter/material.dart'; -import 'package:{{DART_PACKAGE}}/core/api_consts.dart'; -import 'package:{{DART_PACKAGE}}/core/dependencies.dart'; -import 'package:{{DART_PACKAGE}}/presentation/home/navigation_screen.dart'; -import 'package:{{DART_PACKAGE}}/services/notification_service.dart'; -import 'package:{{DART_PACKAGE}}/theme/colors.dart'; -import 'package:{{DART_PACKAGE}}/widgets/routes/fade_page.dart'; - -class SplashPage extends StatefulWidget { - const SplashPage({super.key}); - @override - State createState() => _SplashPageState(); -} - -class _SplashPageState extends State { - @override - void initState() { - super.initState(); - _init(); - } - - Future _init() async { - ApiConsts.setBackendURLs(); - await getIt.get().initialize(); - Timer(const Duration(seconds: 2), () { - Navigator.of(context).pushReplacement(FadePage(page: const LandingNavigation())); - }); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppColors.whiteColor, - body: Center(child: Text('{{APP_NAME}}', style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: AppColors.primaryRedColor))), - ); - } -} -``` - -### FILE: `lib/presentation/home/navigation_screen.dart` - -```dart -import 'package:flutter/material.dart'; -import 'package:{{DART_PACKAGE}}/presentation/home/landing_page.dart'; -import 'package:{{DART_PACKAGE}}/theme/colors.dart'; - -class LandingNavigation extends StatefulWidget { - const LandingNavigation({super.key}); - @override - State createState() => _LandingNavigationState(); -} - -class _LandingNavigationState extends State { - int _idx = 0; - final _pc = PageController(); - - final _pages = const [ - LandingPage(), - Center(child: Text('Medical File')), - Center(child: Text('Book Appointment')), - Center(child: Text('Services')), - ]; - - @override - Widget build(BuildContext context) { - return Scaffold( - body: PageView(controller: _pc, physics: const NeverScrollableScrollPhysics(), children: _pages), - bottomNavigationBar: BottomNavigationBar( - currentIndex: _idx, - type: BottomNavigationBarType.fixed, - selectedItemColor: AppColors.primaryRedColor, - unselectedItemColor: AppColors.greyTextColor, - onTap: (i) { setState(() => _idx = i); _pc.jumpToPage(i); }, - items: const [ - BottomNavigationBarItem(icon: Icon(Icons.home_outlined), activeIcon: Icon(Icons.home), label: 'Home'), - BottomNavigationBarItem(icon: Icon(Icons.folder_outlined), activeIcon: Icon(Icons.folder), label: 'File'), - BottomNavigationBarItem(icon: Icon(Icons.calendar_today_outlined), activeIcon: Icon(Icons.calendar_today), label: 'Book'), - BottomNavigationBarItem(icon: Icon(Icons.grid_view_outlined), activeIcon: Icon(Icons.grid_view), label: 'Services'), - ], - ), - ); - } -} -``` - -### FILE: `lib/presentation/home/landing_page.dart` - -```dart -import 'package:flutter/material.dart'; -import 'package:{{DART_PACKAGE}}/extensions/int_extensions.dart'; -import 'package:{{DART_PACKAGE}}/extensions/string_extensions.dart'; -import 'package:{{DART_PACKAGE}}/theme/colors.dart'; - -class LandingPage extends StatelessWidget { - const LandingPage({super.key}); - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppColors.scaffoldBgColor, - body: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - 24.height, - 'Welcome'.toText24(isBold: true), - 8.height, - 'What would you like to do today?'.toText14(color: AppColors.greyTextColor), - 24.height, - Expanded(child: Center(child: 'Home content goes here'.toText16(color: AppColors.textColorLight, isCenter: true))), - ]), - ), - ), - ); - } -} -``` - ---- - -## 15. ENTRY POINT — `lib/main.dart` - -```dart -import 'dart:io'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:provider/provider.dart'; -import 'package:sizer/sizer.dart'; -import 'package:{{DART_PACKAGE}}/core/dependencies.dart'; -import 'package:{{DART_PACKAGE}}/routes/app_routes.dart'; -import 'package:{{DART_PACKAGE}}/services/navigation_service.dart'; -import 'package:{{DART_PACKAGE}}/theme/app_theme.dart'; -import 'package:{{DART_PACKAGE}}/theme/colors.dart'; - -class MyHttpOverrides extends HttpOverrides { - @override - HttpClient createHttpClient(SecurityContext? context) => - super.createHttpClient(context)..badCertificateCallback = (cert, host, port) => true; -} - -Future _init() async { - WidgetsFlutterBinding.ensureInitialized(); - await EasyLocalization.ensureInitialized(); - // Uncomment when Firebase is configured: - // await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); - await AppDependencies.addDependencies(); - SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); - HttpOverrides.global = MyHttpOverrides(); -} - -void main() async { - await _init(); - runApp( - EasyLocalization( - supportedLocales: const [Locale('en', 'US'), Locale('ar', 'SA')], - path: 'assets/langs', - fallbackLocale: const Locale('en', 'US'), - child: MultiProvider( - providers: [ - // ═══ Add ChangeNotifierProviders as you build features ═══ - ], - child: const MyApp(), - ), - ), - ); -} - -class MyApp extends StatelessWidget { - const MyApp({super.key}); - - @override - Widget build(BuildContext context) { - return SafeArea( - top: false, - bottom: !Platform.isIOS, - child: LayoutBuilder(builder: (context, constraints) { - return Sizer(builder: (context, orientation, deviceType) { - final isArabic = EasyLocalization.of(context)?.locale.languageCode == "ar"; - return MaterialApp( - title: '{{APP_NAME}}', - debugShowCheckedModeBanner: false, - localizationsDelegates: context.localizationDelegates, - supportedLocales: context.supportedLocales, - locale: context.locale, - initialRoute: AppRoutes.initialRoute, - routes: AppRoutes.routes, - theme: AppTheme.getTheme(isArabic), - darkTheme: AppTheme.getDarkTheme(isArabic), - themeMode: AppColors.isDarkMode ? ThemeMode.dark : ThemeMode.light, - navigatorKey: getIt.get().navigatorKey, - builder: (context, child) => MediaQuery( - data: MediaQuery.of(context).copyWith(textScaler: TextScaler.noScaling, alwaysUse24HourFormat: true), - child: child!, - ), - ); - }); - }), - ); - } -} -``` - ---- - -## 16. FEATURE MODULE TEMPLATE - -When asked to **add a new feature**, create these four files. Replace `{{feature}}` (snake_case) and `{{Feature}}` (PascalCase). - -### `lib/features/{{feature}}/{{feature}}_repo.dart` - -```dart -import 'package:dartz/dartz.dart'; -import 'package:{{DART_PACKAGE}}/core/api/api_client.dart'; -import 'package:{{DART_PACKAGE}}/core/common_models/generic_api_model.dart'; -import 'package:{{DART_PACKAGE}}/core/exceptions/api_failure.dart'; -import 'package:{{DART_PACKAGE}}/services/logger_service.dart'; - -abstract class {{Feature}}Repo { - // Future>>> getItems(); -} - -class {{Feature}}RepoImp implements {{Feature}}Repo { - final ApiClient apiClient; - final LoggerService loggerService; - {{Feature}}RepoImp({required this.loggerService, required this.apiClient}); - - // @override - // Future>>> getItems() async { - // Map body = {}; - // try { - // GenericApiModel>? result; - // Failure? failure; - // await apiClient.post('endpoint', body: body, - // onFailure: (e, s, {messageStatus, failureType}) => failure = failureType, - // onSuccess: (r, s, {messageStatus, errorMessage}) { - // try { result = GenericApiModel(data: (r['Key'] as List).map((e) => YourModel.fromJson(e)).toList()); } - // catch (e) { failure = DataParsingFailure(e.toString()); } - // }); - // if (failure != null) return Left(failure!); - // return Right(result!); - // } catch (e) { return Left(UnknownFailure(e.toString())); } - // } -} -``` - -### `lib/features/{{feature}}/{{feature}}_view_model.dart` - -```dart -import 'package:flutter/material.dart'; -import 'package:{{DART_PACKAGE}}/features/{{feature}}/{{feature}}_repo.dart'; -import 'package:{{DART_PACKAGE}}/services/error_handler_service.dart'; -import 'package:{{DART_PACKAGE}}/services/navigation_service.dart'; - -class {{Feature}}ViewModel extends ChangeNotifier { - final {{Feature}}Repo _repo; - final ErrorHandlerService _errorHandler; - final NavigationService _nav; - - {{Feature}}ViewModel({required {{Feature}}Repo repo, required ErrorHandlerService errorHandler, required NavigationService nav}) - : _repo = repo, _errorHandler = errorHandler, _nav = nav; - - bool isLoading = false; - - // void init() { isLoading = true; notifyListeners(); _fetch(); } - // void _fetch() async { - // final r = await _repo.getItems(); - // r.fold((f) { isLoading = false; _errorHandler.handleError(failure: f); notifyListeners(); }, - // (d) { /* items = d.data ?? []; */ isLoading = false; notifyListeners(); }); - // } -} -``` - -### `lib/presentation/{{feature}}/{{feature}}_page.dart` - -```dart -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import 'package:{{DART_PACKAGE}}/features/{{feature}}/{{feature}}_view_model.dart'; -import 'package:{{DART_PACKAGE}}/theme/colors.dart'; - -class {{Feature}}Page extends StatefulWidget { - const {{Feature}}Page({super.key}); - @override - State<{{Feature}}Page> createState() => _{{Feature}}PageState(); -} - -class _{{Feature}}PageState extends State<{{Feature}}Page> { - @override - void initState() { - super.initState(); - // Provider.of<{{Feature}}ViewModel>(context, listen: false).init(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppColors.scaffoldBgColor, - appBar: AppBar(title: const Text('{{Feature}}')), - body: Consumer<{{Feature}}ViewModel>( - builder: (_, vm, __) { - if (vm.isLoading) return const Center(child: CircularProgressIndicator()); - return const Center(child: Text('Content here')); - }, - ), - ); - } -} -``` - -### After creating, REGISTER: - -**`dependencies.dart`:** -```dart -getIt.registerLazySingleton<{{Feature}}Repo>(() => {{Feature}}RepoImp(loggerService: getIt(), apiClient: getIt())); -getIt.registerLazySingleton<{{Feature}}ViewModel>(() => {{Feature}}ViewModel(repo: getIt(), errorHandler: getIt(), nav: getIt())); -``` - -**`main.dart` providers list:** -```dart -ChangeNotifierProvider<{{Feature}}ViewModel>(create: (_) => getIt.get<{{Feature}}ViewModel>()), -``` - -**`app_routes.dart`:** -```dart -static const String {{feature}}Page = '/{{feature}}'; -// in routes map: {{feature}}Page: (_) => const {{Feature}}Page(), -``` - ---- - -## 17. RULES — ALWAYS FOLLOW - -1. **Every service/repo = abstract class + implementation.** Never use concrete directly. -2. **All DI in `dependencies.dart`.** Only use `getIt.get()` elsewhere. -3. **All repos return `Future>>`.** -4. **All ViewModels extend `ChangeNotifier`.** Call `notifyListeners()` after state changes. -5. **ViewModels NEVER import widgets.** Use `NavigationService` for navigation. -6. **Pages use `Consumer` or `Provider.of` to read ViewModel state.** -7. **Colors from `AppColors`** — never hardcode hex in widgets. -8. **Text via string extensions** — `'Hello'.toText16()` not `Text('Hello')`. -9. **Spacing via int extensions** — `16.height` not `SizedBox(height: 16)`. -10. **Sizes via responsive extensions** — `16.f`, `16.w`, `16.h`, `16.r`. -11. **Assets via `AppAssets`** — never hardcode paths. -12. **Cache keys in `CacheConst`** — never raw strings for SharedPreferences. -13. **Endpoints in `ApiConsts`** — never hardcode URLs. -14. **Errors through `ErrorHandlerService`** — VMs never show dialogs. -15. **User-facing strings use `.tr()`** from easy_localization. - ---- - -## 18. CHECKLIST — Verify Before Delivering - -- [ ] `flutter pub get` succeeds -- [ ] All imports use `package:{{DART_PACKAGE}}/...` -- [ ] Every file in this spec exists -- [ ] `dependencies.dart` registers all existing services/repos/VMs -- [ ] `main.dart` providers list includes all registered VMs -- [ ] `app_routes.dart` includes all existing pages -- [ ] `pubspec.yaml` declares all existing asset directories -- [ ] Both `en-US.json` and `ar-SA.json` exist -- [ ] No circular imports -- [ ] `AppColors` used everywhere (no raw `Color()` in widgets) -- [ ] Extensions used for text, spacing, widget wrapping - ---- - -*End of specification. Generate all files above. When asked to add a feature, use Section 16.* - diff --git a/assets/langs/ar-SA.json b/assets/langs/ar-SA.json index a63f0706..cd9d7c8e 100644 --- a/assets/langs/ar-SA.json +++ b/assets/langs/ar-SA.json @@ -908,6 +908,7 @@ "general": "عام", "liveCare": "لايف كير", "recentVisits": "الزيارات الأخيرة", + "favouriteDoctors": "الأطباء المفضلون", "searchByClinic": "البحث حسب العيادة", "tapToSelectClinic": "انقر لاختيار العيادة", "searchByDoctor": "البحث حسب الطبيب", diff --git a/assets/langs/en-US.json b/assets/langs/en-US.json index ec4c032a..438dc0c5 100644 --- a/assets/langs/en-US.json +++ b/assets/langs/en-US.json @@ -902,6 +902,7 @@ "general": "General", "liveCare": "LiveCare", "recentVisits": "Recent Visits", + "favouriteDoctors": "Favourite Doctors", "searchByClinic": "Search By Clinic", "tapToSelectClinic": "Tap to select clinic", "searchByDoctor": "Search By Doctor", diff --git a/lib/core/api_consts.dart b/lib/core/api_consts.dart index 898d5661..73cb287f 100644 --- a/lib/core/api_consts.dart +++ b/lib/core/api_consts.dart @@ -4,7 +4,7 @@ import 'package:hmg_patient_app_new/core/enums.dart'; class ApiConsts { static const maxSmallScreen = 660; - static AppEnvironmentTypeEnum appEnvironmentType = AppEnvironmentTypeEnum.prod; + static AppEnvironmentTypeEnum appEnvironmentType = AppEnvironmentTypeEnum.preProd; // static String baseUrl = 'https://uat.hmgwebservices.com/'; // HIS API URL UAT @@ -229,6 +229,7 @@ class ApiConsts { static String getPatientBloodGroup = "services/PatientVarification.svc/REST/BloodDonation_GetBloodGroupDetails"; static String getPatientBloodAgreement = "Services/PatientVarification.svc/REST/CheckUserAgreementForBloodDonation"; + static String getPatientBloodTypeNew = "Services/Patients.svc/REST/HIS_GetPatientBloodType_New"; static String getAiOverViewLabOrders = "Services/Patients.svc/REST/HMGAI_Lab_Analyze_Orders_API"; static String getAiOverViewLabOrder = "Services/Patients.svc/REST/HMGAI_Lab_Analyzer_API"; @@ -468,6 +469,15 @@ var GET_DENTAL_DOCTORS_LIST_URL = "Services/Doctors.svc/REST/Dental_DoctorChiefC //URL to get doctor free slots var GET_DOCTOR_FREE_SLOTS = "Services/Doctors.svc/REST/GetDoctorFreeSlots"; +//URL to check if doctor is favorite +var IS_FAVOURITE_DOCTOR = "Services/Patients.svc/REST/Patient_IsFavouriteDoctor"; + +//URL to get favorite doctors list +var GET_FAVOURITE_DOCTOR = "Services/Patients.svc/REST/Patient_GetFavouriteDoctor"; + +//URL to insert favorite doctor +var INSERT_FAVOURITE_DOCTOR = "Services/Patients.svc/REST/Patient_InsertFavouriteDoctor"; + //URL to insert appointment var INSERT_SPECIFIC_APPOINTMENT = "Services/Doctors.svc/REST/InsertSpecificAppointment"; diff --git a/lib/core/app_assets.dart b/lib/core/app_assets.dart index 222a5100..81226f86 100644 --- a/lib/core/app_assets.dart +++ b/lib/core/app_assets.dart @@ -182,6 +182,8 @@ class AppAssets { static const String ic_rrt_vehicle = '$svgBasePath/ic_rrt_vehicle.svg'; static const String doctor_profile_rating_icon = '$svgBasePath/doctor_profile_rating_icon.svg'; static const String doctor_profile_reviews_icon = '$svgBasePath/doctor_profile_reviews_icon.svg'; + static const String bookmark_icon = '$svgBasePath/bookmark_icon.svg'; + static const String bookmark_filled_icon = '$svgBasePath/bookmark_filled_icon.svg'; static const String waiting_appointment_icon = '$svgBasePath/waitingAppo.svg'; static const String call_for_vitals = '$svgBasePath/call_for_vitals.svg'; static const String call_for_doctor = '$svgBasePath/call_for_doctor.svg'; diff --git a/lib/features/authentication/authentication_repo.dart b/lib/features/authentication/authentication_repo.dart index 1271f467..cd8ad70d 100644 --- a/lib/features/authentication/authentication_repo.dart +++ b/lib/features/authentication/authentication_repo.dart @@ -45,6 +45,8 @@ abstract class AuthenticationRepo { Future>> insertPatientDeviceData({required dynamic patientDeviceDataRequest}); Future>> getPatientDeviceData({required dynamic patientDeviceDataRequest}); + + Future>> getPatientBloodType(); } class AuthenticationRepoImp implements AuthenticationRepo { @@ -687,4 +689,37 @@ class AuthenticationRepoImp implements AuthenticationRepo { } } } + + @override + Future>> getPatientBloodType() async { + Map requestBody = {}; + try { + GenericApiModel? apiResponse; + Failure? failure; + await apiClient.post( + ApiConsts.getPatientBloodTypeNew, + body: requestBody, + onFailure: (error, statusCode, {messageStatus, failureType}) { + failure = failureType; + }, + onSuccess: (response, statusCode, {messageStatus, errorMessage}) { + try { + apiResponse = GenericApiModel( + messageStatus: messageStatus, + statusCode: statusCode, + errorMessage: errorMessage, + data: response, + ); + } catch (e) { + failure = DataParsingFailure(e.toString()); + } + }, + ); + if (failure != null) return Left(failure!); + if (apiResponse == null) return Left(ServerFailure("Unknown error")); + return Right(apiResponse!); + } catch (e) { + return Left(UnknownFailure(e.toString())); + } + } } diff --git a/lib/features/authentication/authentication_view_model.dart b/lib/features/authentication/authentication_view_model.dart index ba79b70d..c0bc4dd1 100644 --- a/lib/features/authentication/authentication_view_model.dart +++ b/lib/features/authentication/authentication_view_model.dart @@ -624,8 +624,10 @@ class AuthenticationViewModel extends ChangeNotifier { activation.list!.first.zipCode = selectedCountrySignup == CountryEnum.others ? '0' : selectedCountrySignup.countryCode; _appState.setAuthenticatedUser(activation.list!.first); _appState.setPrivilegeModelList(activation.list!.first.listPrivilege!); - // _appState.setUserBloodGroup = activation.patientBlodType ?? "N/A"; - _appState.setUserBloodGroup = activation.patientBloodType ?? "N/A"; + _appState.setUserBloodGroup = activation.patientBlodType ?? "N/A"; + + // Fetch patient blood type from new API + await getPatientBloodTypeNew(); } // _appState.setUserBloodGroup = (activation.patientBlodType ?? ""); _appState.setAppAuthToken = activation.authenticationTokenId; @@ -1153,4 +1155,29 @@ class AuthenticationViewModel extends ChangeNotifier { _navigationService.pushAndReplace(AppRoutes.landingScreen); } } + + Future getPatientBloodTypeNew() async { + try { + final result = await _authenticationRepo.getPatientBloodType(); + + result.fold( + (failure) async { + // Log error but don't show to user, keep existing blood type + log("Failed to fetch blood type: ${failure.message}"); + }, + (apiResponse) { + if (apiResponse.messageStatus == 1 && apiResponse.data != null) { + // Extract blood type from response + String? bloodType = apiResponse.data['GetPatientBloodType']; + if (bloodType != null && bloodType.isNotEmpty) { + _appState.setUserBloodGroup = bloodType; + log("Blood type updated from new API: $bloodType"); + } + } + }, + ); + } catch (e) { + log("Error calling getPatientBloodType: $e"); + } + } } diff --git a/lib/features/book_appointments/book_appointments_repo.dart b/lib/features/book_appointments/book_appointments_repo.dart index fc541d2e..7a984399 100644 --- a/lib/features/book_appointments/book_appointments_repo.dart +++ b/lib/features/book_appointments/book_appointments_repo.dart @@ -107,6 +107,12 @@ abstract class BookAppointmentsRepo { Function(String)? onError}); Future>> getAppointmentNearestGate({required int projectID, required int clinicID}); + + Future>> isFavouriteDoctor( + {required int patientID, required int projectID, required int clinicID, required int doctorID, Function(dynamic)? onSuccess, Function(String)? onError}); + + Future>> insertFavouriteDoctor( + {required int patientID, required int projectID, required int clinicID, required int doctorID, required bool isActive, Function(dynamic)? onSuccess, Function(String)? onError}); } class BookAppointmentsRepoImp implements BookAppointmentsRepo { @@ -1133,4 +1139,86 @@ class BookAppointmentsRepoImp implements BookAppointmentsRepo { return Left(UnknownFailure(e.toString())); } } + + @override + Future>> isFavouriteDoctor( + {required int patientID, required int projectID, required int clinicID, required int doctorID, Function(dynamic)? onSuccess, Function(String)? onError}) async { + Map mapRequest = {"PatientID": patientID, "ProjectID": projectID, "ClinicID": clinicID, "DoctorID": doctorID}; + + try { + GenericApiModel? apiResponse; + Failure? failure; + await apiClient.post( + IS_FAVOURITE_DOCTOR, + body: mapRequest, + onFailure: (error, statusCode, {messageStatus, failureType}) { + failure = failureType; + if (onError != null) { + onError(error); + } + }, + onSuccess: (response, statusCode, {messageStatus, errorMessage}) { + try { + apiResponse = GenericApiModel( + messageStatus: messageStatus, + statusCode: statusCode, + errorMessage: null, + data: response["IsFavouriteDoctor"], + ); + if (onSuccess != null) { + onSuccess(response); + } + } catch (e) { + failure = DataParsingFailure(e.toString()); + } + }, + ); + if (failure != null) return Left(failure!); + if (apiResponse == null) return Left(ServerFailure("Unknown error")); + return Right(apiResponse!); + } catch (e) { + return Left(UnknownFailure(e.toString())); + } + } + + @override + Future>> insertFavouriteDoctor( + {required int patientID, required int projectID, required int clinicID, required int doctorID, required bool isActive, Function(dynamic)? onSuccess, Function(String)? onError}) async { + Map mapRequest = {"PatientID": patientID, "ProjectID": projectID, "ClinicID": clinicID, "DoctorID": doctorID, "IsActive": isActive}; + + try { + GenericApiModel? apiResponse; + Failure? failure; + await apiClient.post( + INSERT_FAVOURITE_DOCTOR, + body: mapRequest, + onFailure: (error, statusCode, {messageStatus, failureType}) { + failure = failureType; + if (onError != null) { + onError(error); + } + }, + onSuccess: (response, statusCode, {messageStatus, errorMessage}) { + try { + apiResponse = GenericApiModel( + messageStatus: messageStatus, + statusCode: statusCode, + errorMessage: null, + data: response, + ); + if (onSuccess != null) { + onSuccess(response); + } + } catch (e) { + failure = DataParsingFailure(e.toString()); + } + }, + ); + if (failure != null) return Left(failure!); + if (apiResponse == null) return Left(ServerFailure("Unknown error")); + return Right(apiResponse!); + } catch (e) { + return Left(UnknownFailure(e.toString())); + } + } } diff --git a/lib/features/book_appointments/book_appointments_view_model.dart b/lib/features/book_appointments/book_appointments_view_model.dart index 0d2a8f82..afc9b568 100644 --- a/lib/features/book_appointments/book_appointments_view_model.dart +++ b/lib/features/book_appointments/book_appointments_view_model.dart @@ -98,6 +98,8 @@ class BookAppointmentsViewModel extends ChangeNotifier { bool isDoctorRatingDetailsLoading = false; List doctorDetailsList = []; + bool isFavouriteDoctor = false; + List slotsList = []; List docFreeSlots = []; List dayEvents = []; @@ -648,6 +650,15 @@ class BookAppointmentsViewModel extends ChangeNotifier { } else if (apiResponse.messageStatus == 1) { doctorsProfileResponseModel = apiResponse.data!; notifyListeners(); + + // Check if doctor is favorite after getting profile + checkIsFavouriteDoctor( + patientID: _appState.getAuthenticatedUser()!.patientId!, + projectID: doctorsProfileResponseModel.projectID ?? 0, + clinicID: doctorsProfileResponseModel.clinicID ?? 0, + doctorID: doctorsProfileResponseModel.doctorID ?? 0, + ); + if (onSuccess != null) { onSuccess(apiResponse); } @@ -1530,4 +1541,79 @@ class BookAppointmentsViewModel extends ChangeNotifier { }, ); } + + void toggleFavouriteDoctor() { + isFavouriteDoctor = !isFavouriteDoctor; + notifyListeners(); + } + + void setIsFavouriteDoctor(bool value) { + isFavouriteDoctor = value; + notifyListeners(); + } + + Future checkIsFavouriteDoctor({required int patientID, required int projectID, required int clinicID, required int doctorID, Function(dynamic)? onSuccess, Function(String)? onError}) async { + final result = await bookAppointmentsRepo.isFavouriteDoctor( + patientID: patientID, + projectID: projectID, + clinicID: clinicID, + doctorID: doctorID, + onSuccess: onSuccess, + onError: onError, + ); + + result.fold( + (failure) async { + if (onError != null) { + onError(failure.message); + } + }, + (apiResponse) { + if (apiResponse.messageStatus == 2) { + if (onError != null) { + onError(apiResponse.errorMessage ?? "Failed to check favorite doctor"); + } + } else if (apiResponse.messageStatus == 1) { + // Check the response for IsFavouriteDoctor flag + bool isFavorite = apiResponse.data; + setIsFavouriteDoctor(isFavorite); + if (onSuccess != null) { + onSuccess(apiResponse.data); + } + } + }, + ); + } + + Future insertFavouriteDoctor({required int patientID, required int projectID, required int clinicID, required int doctorID, required bool isActive, Function(dynamic)? onSuccess, Function(String)? onError}) async { + final result = await bookAppointmentsRepo.insertFavouriteDoctor( + patientID: patientID, + projectID: projectID, + clinicID: clinicID, + doctorID: doctorID, + isActive: isActive, + onSuccess: onSuccess, + onError: onError, + ); + + result.fold( + (failure) async { + if (onError != null) { + onError(failure.message); + } + }, + (apiResponse) { + if (apiResponse.messageStatus == 2) { + if (onError != null) { + onError(apiResponse.errorMessage ?? "Failed to update favorite doctor"); + } + } else if (apiResponse.messageStatus == 1) { + notifyListeners(); + if (onSuccess != null) { + onSuccess(apiResponse.data); + } + } + }, + ); + } } diff --git a/lib/features/book_appointments/models/resp_models/get_favorite_doctors_list.dart b/lib/features/book_appointments/models/resp_models/get_favorite_doctors_list.dart new file mode 100644 index 00000000..b3048e67 --- /dev/null +++ b/lib/features/book_appointments/models/resp_models/get_favorite_doctors_list.dart @@ -0,0 +1,81 @@ +import 'dart:convert'; + +class GetFavoriteDoctorsListModel { + int? id; + int? projectId; + int? clinicId; + int? doctorId; + int? patientId; + bool? patientOutSa; + bool? isActive; + String? createdOn; + dynamic modifiedOn; + String? doctorImageUrl; + String? doctorName; + String? doctorTitle; + String? nationalityFlagUrl; + String? nationalityId; + String? nationalityName; + List? speciality; + + GetFavoriteDoctorsListModel({ + this.id, + this.projectId, + this.clinicId, + this.doctorId, + this.patientId, + this.patientOutSa, + this.isActive, + this.createdOn, + this.modifiedOn, + this.doctorImageUrl, + this.doctorName, + this.doctorTitle, + this.nationalityFlagUrl, + this.nationalityId, + this.nationalityName, + this.speciality, + }); + + factory GetFavoriteDoctorsListModel.fromRawJson(String str) => GetFavoriteDoctorsListModel.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + factory GetFavoriteDoctorsListModel.fromJson(Map json) => GetFavoriteDoctorsListModel( + id: json["ID"], + projectId: json["ProjectID"], + clinicId: json["ClinicID"], + doctorId: json["DoctorID"], + patientId: json["PatientID"], + patientOutSa: json["PatientOutSA"], + isActive: json["IsActive"], + createdOn: json["CreatedOn"], + modifiedOn: json["ModifiedOn"], + doctorImageUrl: json["DoctorImageURL"], + doctorName: json["DoctorName"], + doctorTitle: json["DoctorTitle"], + nationalityFlagUrl: json["NationalityFlagURL"], + nationalityId: json["NationalityID"], + nationalityName: json["NationalityName"], + speciality: json["Speciality"] == null ? [] : List.from(json["Speciality"]!.map((x) => x)), + ); + + Map toJson() => { + "ID": id, + "ProjectID": projectId, + "ClinicID": clinicId, + "DoctorID": doctorId, + "PatientID": patientId, + "PatientOutSA": patientOutSa, + "IsActive": isActive, + "CreatedOn": createdOn, + "ModifiedOn": modifiedOn, + "DoctorImageURL": doctorImageUrl, + "DoctorName": doctorName, + "DoctorTitle": doctorTitle, + "NationalityFlagURL": nationalityFlagUrl, + "NationalityID": nationalityId, + "NationalityName": nationalityName, + "Speciality": speciality == null ? [] : List.from(speciality!.map((x) => x)), + }; +} diff --git a/lib/features/my_appointments/my_appointments_repo.dart b/lib/features/my_appointments/my_appointments_repo.dart index 567714c6..ccf76ce0 100644 --- a/lib/features/my_appointments/my_appointments_repo.dart +++ b/lib/features/my_appointments/my_appointments_repo.dart @@ -8,6 +8,7 @@ import 'package:hmg_patient_app_new/core/common_models/generic_api_model.dart'; import 'package:hmg_patient_app_new/core/exceptions/api_failure.dart'; import 'package:hmg_patient_app_new/core/utils/date_util.dart'; import 'package:hmg_patient_app_new/core/utils/utils.dart'; +import 'package:hmg_patient_app_new/features/book_appointments/models/resp_models/get_favorite_doctors_list.dart'; import 'package:hmg_patient_app_new/features/my_appointments/models/resp_models/rate_appointment_resp_model.dart'; import 'package:hmg_patient_app_new/features/my_appointments/models/resp_models/ask_doctor_request_type_response_model.dart'; import 'package:hmg_patient_app_new/features/my_appointments/models/resp_models/get_tamara_installments_details_response_model.dart'; @@ -51,6 +52,8 @@ abstract class MyAppointmentsRepo { Future>>> getPatientDoctorsList(); + Future>>> getFavouriteDoctorsList(); + Future>> insertLiveCareVIDARequest({required clientRequestID, required PatientAppointmentHistoryResponseModel patientAppointmentHistoryResponseModel}); Future>> getTamaraInstallmentsDetails(); @@ -531,6 +534,54 @@ class MyAppointmentsRepoImp implements MyAppointmentsRepo { } } + @override + Future>>> getFavouriteDoctorsList() async { + Map mapDevice = {}; + + try { + GenericApiModel>? apiResponse; + Failure? failure; + await apiClient.post( + GET_FAVOURITE_DOCTOR, + body: mapDevice, + onFailure: (error, statusCode, {messageStatus, failureType}) { + failure = failureType; + }, + onSuccess: (response, statusCode, {messageStatus, errorMessage}) { + try { + final list = response['Patient_GetFavouriteDoctorList']; + + if (list == null || list.isEmpty) { + apiResponse = GenericApiModel>( + messageStatus: messageStatus, + statusCode: statusCode, + errorMessage: null, + data: [], + ); + return; + } + + final appointmentsList = (list as List).map((item) => GetFavoriteDoctorsListModel.fromJson(item as Map)).toList().cast(); + + apiResponse = GenericApiModel>( + messageStatus: messageStatus, + statusCode: statusCode, + errorMessage: null, + data: appointmentsList, + ); + } catch (e) { + failure = DataParsingFailure(e.toString()); + } + }, + ); + if (failure != null) return Left(failure!); + if (apiResponse == null) return Left(ServerFailure("Unknown error")); + return Right(apiResponse!); + } catch (e) { + return Left(UnknownFailure(e.toString())); + } + } + @override Future> insertLiveCareVIDARequest({required clientRequestID, required PatientAppointmentHistoryResponseModel patientAppointmentHistoryResponseModel}) async { Map requestBody = { diff --git a/lib/features/my_appointments/my_appointments_view_model.dart b/lib/features/my_appointments/my_appointments_view_model.dart index 1584f891..aa56d440 100644 --- a/lib/features/my_appointments/my_appointments_view_model.dart +++ b/lib/features/my_appointments/my_appointments_view_model.dart @@ -5,6 +5,7 @@ import 'package:hmg_patient_app_new/core/app_state.dart'; import 'package:hmg_patient_app_new/core/utils/date_util.dart'; import 'package:hmg_patient_app_new/core/utils/size_utils.dart'; import 'package:hmg_patient_app_new/core/utils/utils.dart'; +import 'package:hmg_patient_app_new/features/book_appointments/models/resp_models/get_favorite_doctors_list.dart'; import 'package:hmg_patient_app_new/features/my_appointments/models/appointemnet_filters.dart'; import 'package:hmg_patient_app_new/features/my_appointments/models/resp_models/ask_doctor_request_type_response_model.dart'; import 'package:hmg_patient_app_new/features/my_appointments/models/resp_models/get_tamara_installments_details_response_model.dart'; @@ -37,6 +38,8 @@ class MyAppointmentsViewModel extends ChangeNotifier { bool isAppointmentPatientShareLoading = false; bool isTimeLineAppointmentsLoading = false; bool isPatientMyDoctorsLoading = false; + bool isPatientFavouriteDoctorsLoading = false; + bool isFavouriteDoctorsDataFetched = false; bool isAppointmentDataToBeLoaded = true; @@ -64,6 +67,8 @@ class MyAppointmentsViewModel extends ChangeNotifier { List patientMyDoctorsList = []; + List patientFavouriteDoctorsList = []; + List patientEyeMeasurementsAppointmentsHistoryList = []; // Grouping by Clinic and Hospital @@ -659,6 +664,51 @@ class MyAppointmentsViewModel extends ChangeNotifier { ); } + Future getPatientFavouriteDoctors({bool forceRefresh = false, Function(dynamic)? onSuccess, Function(String)? onError}) async { + // If data is already fetched and not forcing refresh, skip API call + if (isFavouriteDoctorsDataFetched && !forceRefresh) { + return; + } + + isPatientFavouriteDoctorsLoading = true; + patientFavouriteDoctorsList.clear(); + notifyListeners(); + + final result = await myAppointmentsRepo.getFavouriteDoctorsList(); + + result.fold( + (failure) async { + isPatientFavouriteDoctorsLoading = false; + notifyListeners(); + }, + (apiResponse) { + if (apiResponse.messageStatus == 2) { + isPatientFavouriteDoctorsLoading = false; + notifyListeners(); + } else if (apiResponse.messageStatus == 1) { + patientFavouriteDoctorsList = apiResponse.data!; + isFavouriteDoctorsDataFetched = true; + isPatientFavouriteDoctorsLoading = false; + notifyListeners(); + if (onSuccess != null) { + onSuccess(apiResponse); + } + } + }, + ); + } + + // Method to force refresh favorite doctors list + void refreshFavouriteDoctors() { + isFavouriteDoctorsDataFetched = false; + getPatientFavouriteDoctors(forceRefresh: true); + } + + // Method to reset favorite doctors cache + void resetFavouriteDoctorsCache() { + isFavouriteDoctorsDataFetched = false; + } + Future insertLiveCareVIDARequest( {required clientRequestID, required PatientAppointmentHistoryResponseModel patientAppointmentHistoryResponseModel, Function(dynamic)? onSuccess, Function(String)? onError}) async { final result = await myAppointmentsRepo.insertLiveCareVIDARequest(clientRequestID: clientRequestID, patientAppointmentHistoryResponseModel: patientAppointmentHistoryResponseModel); diff --git a/lib/generated/locale_keys.g.dart b/lib/generated/locale_keys.g.dart index f365d209..2ca58bc1 100644 --- a/lib/generated/locale_keys.g.dart +++ b/lib/generated/locale_keys.g.dart @@ -906,6 +906,7 @@ abstract class LocaleKeys { static const vat15 = 'vat15'; static const liveCare = 'liveCare'; static const recentVisits = 'recentVisits'; + static const favouriteDoctors = 'favouriteDoctors'; static const searchByClinic = 'searchByClinic'; static const tapToSelectClinic = 'tapToSelectClinic'; static const searchByDoctor = 'searchByDoctor'; diff --git a/lib/presentation/book_appointment/book_appointment_page.dart b/lib/presentation/book_appointment/book_appointment_page.dart index feee3b83..be1d9aad 100644 --- a/lib/presentation/book_appointment/book_appointment_page.dart +++ b/lib/presentation/book_appointment/book_appointment_page.dart @@ -63,12 +63,14 @@ class _BookAppointmentPageState extends State { immediateLiveCareViewModel.initImmediateLiveCare(); if (appState.isAuthenticated) { getIt.get().getPatientMyDoctors(); + getIt.get().getPatientFavouriteDoctors(); } }); WidgetsBinding.instance.addPostFrameCallback((_) { if (bookAppointmentsViewModel.selectedTabIndex == 1) { if (appState.isAuthenticated) { getIt.get().getPatientMyDoctors(); + getIt.get().getPatientFavouriteDoctors(); showUnKnownClinicBottomSheet(); } } else { @@ -220,6 +222,136 @@ class _BookAppointmentPageState extends State { ], ); }), + // Favorite Doctors Section + Consumer(builder: (context, myAppointmentsVM, child) { + // Show shimmer loading state + if (myAppointmentsVM.isPatientFavouriteDoctorsLoading) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(height: 24.h), + LocaleKeys.favouriteDoctors.tr(context: context).toText18(isBold: true).paddingSymmetrical(24.w, 0.h), + SizedBox(height: 16.h), + SizedBox( + height: 110.h, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: 3, // Show 3 shimmer items + shrinkWrap: true, + padding: EdgeInsets.only(left: 24.w, right: 24.w), + itemBuilder: (context, index) { + return SizedBox( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 64.h, + height: 64.h, + decoration: BoxDecoration( + color: Colors.grey[300], + shape: BoxShape.circle, + ), + ).toShimmer2(isShow: true, radius: 50.r), + SizedBox(height: 8.h), + SizedBox( + width: 80.w, + child: Container( + height: 12.h, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(4.r), + ), + ).toShimmer2(isShow: true), + ), + ], + ), + ); + }, + separatorBuilder: (BuildContext cxt, int index) => SizedBox(width: 8.h), + ), + ), + ], + ); + } + + // Show empty state or actual list + return myAppointmentsVM.patientFavouriteDoctorsList.isEmpty + ? SizedBox() + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(height: 24.h), + LocaleKeys.favouriteDoctors.tr(context: context).toText18(isBold: true).paddingSymmetrical(24.w, 0.h), + SizedBox(height: 16.h), + SizedBox( + height: 110.h, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: myAppointmentsVM.patientFavouriteDoctorsList.length, + shrinkWrap: true, + padding: EdgeInsets.only(left: 24.w, right: 24.w), + itemBuilder: (context, index) { + return AnimationConfiguration.staggeredList( + position: index, + duration: const Duration(milliseconds: 1000), + child: SlideAnimation( + horizontalOffset: 100.0, + child: FadeInAnimation( + child: SizedBox( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Image.network( + myAppointmentsVM.patientFavouriteDoctorsList[index].doctorImageUrl!, + width: 64.h, + height: 64.h, + fit: BoxFit.cover, + ).circle(100).toShimmer2(isShow: false, radius: 50.r), + SizedBox(height: 8.h), + SizedBox( + width: 80.w, + child: (myAppointmentsVM.patientFavouriteDoctorsList[index].doctorName) + .toString() + .toText12(fontWeight: FontWeight.w500, isCenter: true, maxLine: 2) + .toShimmer2(isShow: false), + ), + ], + ), + ).onPress(() async { + bookAppointmentsViewModel.setSelectedDoctor(DoctorsListResponseModel( + clinicID: myAppointmentsVM.patientFavouriteDoctorsList[index].clinicId, + projectID: myAppointmentsVM.patientFavouriteDoctorsList[index].projectId, + doctorID: myAppointmentsVM.patientFavouriteDoctorsList[index].doctorId, + )); + LoaderBottomSheet.showLoader(); + await bookAppointmentsViewModel.getDoctorProfile(onSuccess: (dynamic respData) { + LoaderBottomSheet.hideLoader(); + Navigator.of(context).push( + CustomPageRoute( + page: DoctorProfilePage(), + ), + ); + }, onError: (err) { + LoaderBottomSheet.hideLoader(); + showCommonBottomSheetWithoutHeight( + context, + child: Utils.getErrorWidget(loadingText: err), + callBackFunc: () {}, + isFullScreen: false, + isCloseButtonVisible: true, + ); + }); + }), + ), + ), + ); + }, + separatorBuilder: (BuildContext cxt, int index) => SizedBox(width: 8.h), + ), + ), + ], + ); + }), ], ], ); @@ -519,7 +651,7 @@ class _BookAppointmentPageState extends State { } }, onHospitalSearch: (value) { - data.searchHospitals(value ?? ""); + data.searchHospitals(value); }, selectedFacility: data.selectedFacility, hmcCount: data.hmcCount, @@ -532,7 +664,7 @@ class _BookAppointmentPageState extends State { // Navigator.of(context).pop(); bookAppointmentsViewModel.setIsClinicsListLoading(true); bookAppointmentsViewModel.setLoadSpecificClinic(true); - bookAppointmentsViewModel.setProjectID(regionalViewModel.selectedHospital?.hospitalList.first?.mainProjectID.toString()); + bookAppointmentsViewModel.setProjectID(regionalViewModel.selectedHospital!.hospitalList.first!.mainProjectID.toString()); } else { SizedBox.shrink(); } diff --git a/lib/presentation/book_appointment/doctor_profile_page.dart b/lib/presentation/book_appointment/doctor_profile_page.dart index 594306fe..2bfde02e 100644 --- a/lib/presentation/book_appointment/doctor_profile_page.dart +++ b/lib/presentation/book_appointment/doctor_profile_page.dart @@ -8,6 +8,7 @@ import 'package:hmg_patient_app_new/core/utils/utils.dart'; import 'package:hmg_patient_app_new/extensions/string_extensions.dart'; import 'package:hmg_patient_app_new/extensions/widget_extensions.dart'; import 'package:hmg_patient_app_new/features/book_appointments/book_appointments_view_model.dart'; +import 'package:hmg_patient_app_new/features/my_appointments/my_appointments_view_model.dart'; import 'package:hmg_patient_app_new/generated/locale_keys.g.dart'; import 'package:hmg_patient_app_new/presentation/book_appointment/widgets/appointment_calendar.dart'; import 'package:hmg_patient_app_new/presentation/book_appointment/widgets/doctor_rating_details.dart'; @@ -20,15 +21,12 @@ import 'package:hmg_patient_app_new/widgets/loader/bottomsheet_loader.dart'; import 'package:provider/provider.dart'; class DoctorProfilePage extends StatelessWidget { - DoctorProfilePage({super.key}); - - late AppState appState; - late BookAppointmentsViewModel bookAppointmentsViewModel; + const DoctorProfilePage({super.key}); @override Widget build(BuildContext context) { - bookAppointmentsViewModel = Provider.of(context, listen: false); - appState = getIt.get(); + final bookAppointmentsViewModel = Provider.of(context, listen: false); + final appState = getIt.get(); return Scaffold( backgroundColor: AppColors.bgScaffoldColor, body: Column( @@ -36,6 +34,41 @@ class DoctorProfilePage extends StatelessWidget { Expanded( child: CollapsingListView( title: LocaleKeys.doctorProfile.tr(), + trailing: Consumer( + builder: (context, viewModel, child) { + return SizedBox( + width: 24.h, + height: 24.h, + child: Utils.buildSvgWithAssets( + icon: viewModel.isFavouriteDoctor ? AppAssets.bookmark_filled_icon : AppAssets.bookmark_icon, + width: 24.h, + height: 24.h, + iconColor: viewModel.isFavouriteDoctor ? AppColors.primaryRedColor : AppColors.textColor, + ).onPress(() async { + viewModel.toggleFavouriteDoctor(); + await viewModel.insertFavouriteDoctor( + patientID: appState.getAuthenticatedUser()!.patientId!, + projectID: viewModel.doctorsProfileResponseModel.projectID ?? 0, + clinicID: viewModel.doctorsProfileResponseModel.clinicID ?? 0, + doctorID: viewModel.doctorsProfileResponseModel.doctorID ?? 0, + isActive: viewModel.isFavouriteDoctor, + onSuccess: (response) { + // Successfully added/removed favorite - refresh the favorites list + getIt.get().refreshFavouriteDoctors(); + print( + viewModel.isFavouriteDoctor ? "Doctor added to favorites" : "Doctor removed from favorites", + ); + }, + onError: (error) { + // Revert the state on error + viewModel.toggleFavouriteDoctor(); + Utils.showToast(error); + }, + ); + }), + ); + }, + ), child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start,