theme code updated.
parent
5cee0d4185
commit
8b9f4d98f3
@ -0,0 +1,11 @@
|
||||
{
|
||||
"key": "none",
|
||||
"enabled": false,
|
||||
"start_date": null,
|
||||
"end_date": null,
|
||||
"primary_color": null,
|
||||
"background_opacity_image": null,
|
||||
"background_image_opacity": 0.2,
|
||||
"header_image": null
|
||||
}
|
||||
|
||||
@ -0,0 +1,117 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Model representing a remotely-configurable "occasion" theme
|
||||
/// (e.g. Saudi National Day, Founding Day, Eid, Ramadan, ...).
|
||||
///
|
||||
/// Everything here is fully data-driven — nothing about a specific
|
||||
/// occasion (name, color, dates, banner asset) is hardcoded in the app.
|
||||
/// All values are supplied via Firebase Remote Config (or the bundled
|
||||
/// default JSON fallback used before the first successful fetch).
|
||||
@immutable
|
||||
class OccasionConfigModel {
|
||||
/// Unique identifier of the occasion (e.g. "national_day"). Used only
|
||||
/// for logging/analytics — no app logic branches on this value.
|
||||
final String key;
|
||||
|
||||
/// Master kill-switch. When false, the app must look exactly like the
|
||||
/// default/current theme, regardless of any other field below.
|
||||
final bool enabled;
|
||||
|
||||
/// Optional activation window. When both are present, the theme is only
|
||||
/// considered active if `DateTime.now()` falls within [startDate, endDate].
|
||||
/// When null, the [enabled] flag alone controls activation.
|
||||
final DateTime? startDate;
|
||||
final DateTime? endDate;
|
||||
|
||||
/// Hex string (e.g. "#00894A") for the occasion's primary brand color.
|
||||
/// When null, the app's default primary color is preserved.
|
||||
final String? primaryColorHex;
|
||||
|
||||
/// Remote (or bundled asset path) image rendered as a decorative
|
||||
/// background/watermark, drawn using [backgroundImageOpacity].
|
||||
final String? backgroundOpacityImage;
|
||||
|
||||
/// Opacity (0.0 - 1.0) applied to [backgroundOpacityImage].
|
||||
final double backgroundImageOpacity;
|
||||
|
||||
/// Remote (or bundled asset path) image rendered as the occasion's main
|
||||
/// header/banner (e.g. the National Day greeting banner), drawn at full
|
||||
/// opacity on top of [backgroundOpacityImage].
|
||||
final String? headerImage;
|
||||
|
||||
const OccasionConfigModel({
|
||||
required this.key,
|
||||
required this.enabled,
|
||||
this.startDate,
|
||||
this.endDate,
|
||||
this.primaryColorHex,
|
||||
this.backgroundOpacityImage,
|
||||
this.backgroundImageOpacity = 1.0,
|
||||
this.headerImage,
|
||||
});
|
||||
|
||||
/// Fully inert config — identical to the app's current/default look.
|
||||
factory OccasionConfigModel.disabled() => const OccasionConfigModel(
|
||||
key: 'none',
|
||||
enabled: false,
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
primaryColorHex: null,
|
||||
backgroundOpacityImage: null,
|
||||
backgroundImageOpacity: 1.0,
|
||||
headerImage: null,
|
||||
);
|
||||
|
||||
factory OccasionConfigModel.fromJson(Map<String, dynamic> json) {
|
||||
return OccasionConfigModel(
|
||||
key: (json['key'] as String?) ?? 'none',
|
||||
enabled: (json['enabled'] as bool?) ?? false,
|
||||
startDate: _parseDate(json['start_date']),
|
||||
endDate: _parseDate(json['end_date']),
|
||||
primaryColorHex: json['primary_color'] as String?,
|
||||
backgroundOpacityImage: json['background_opacity_image'] as String?,
|
||||
backgroundImageOpacity: (json['background_image_opacity'] as num?)?.toDouble() ?? 1.0,
|
||||
headerImage: json['header_image'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
static DateTime? _parseDate(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is String && value.isNotEmpty) {
|
||||
return DateTime.tryParse(value);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Whether "now" falls inside the configured activation window.
|
||||
/// If no window is configured, the [enabled] flag alone decides.
|
||||
bool isWithinActiveWindow([DateTime? now]) {
|
||||
final current = now ?? DateTime.now();
|
||||
if (startDate != null && current.isBefore(startDate!)) return false;
|
||||
if (endDate != null && current.isAfter(endDate!)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Parsed [Color] from [primaryColorHex], or null if not configured.
|
||||
Color? get primaryColor => _hexToColor(primaryColorHex);
|
||||
|
||||
static Color? _hexToColor(String? hex) {
|
||||
if (hex == null || hex.isEmpty) return null;
|
||||
var value = hex.trim().replaceFirst('#', '');
|
||||
if (value.length == 6) value = 'FF$value';
|
||||
final parsed = int.tryParse(value, radix: 16);
|
||||
return parsed != null ? Color(parsed) : null;
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'key': key,
|
||||
'enabled': enabled,
|
||||
'start_date': startDate?.toIso8601String(),
|
||||
'end_date': endDate?.toIso8601String(),
|
||||
'primary_color': primaryColorHex,
|
||||
'background_opacity_image': backgroundOpacityImage,
|
||||
'background_image_opacity': backgroundImageOpacity,
|
||||
'header_image': headerImage,
|
||||
};
|
||||
}
|
||||
|
||||
@ -0,0 +1,102 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hmg_patient_app_new/features/occasion_theme/occasion_config_model.dart';
|
||||
import 'package:hmg_patient_app_new/services/cache_service.dart';
|
||||
import 'package:hmg_patient_app_new/services/logger_service.dart';
|
||||
import 'package:hmg_patient_app_new/services/remote_config_service.dart';
|
||||
import 'package:hmg_patient_app_new/theme/colors.dart';
|
||||
|
||||
/// Drives the fully remote-config-based "occasion" theming (National Day,
|
||||
/// Founding Day, Eid, Ramadan, ...). Nothing about a specific occasion is
|
||||
/// hardcoded here — every color/asset/date comes from [OccasionConfigModel],
|
||||
/// which is parsed from Firebase Remote Config.
|
||||
///
|
||||
/// Mirrors the existing dark-mode pattern in ProfileSettingsViewModel:
|
||||
/// a static flag on [AppColors] is kept in sync so every widget using
|
||||
/// AppColors.primaryRedColor (even ones with no BuildContext/Provider
|
||||
/// access) reflects the active occasion instantly.
|
||||
class OccasionThemeViewModel extends ChangeNotifier {
|
||||
static const String _cachedConfigKey = 'cached_occasion_config_json';
|
||||
|
||||
final RemoteConfigService _remoteConfigService;
|
||||
final CacheService _cacheService;
|
||||
final LoggerService _loggerService;
|
||||
|
||||
OccasionThemeViewModel({
|
||||
required RemoteConfigService remoteConfigService,
|
||||
required CacheService cacheService,
|
||||
required LoggerService loggerService,
|
||||
}) : _remoteConfigService = remoteConfigService,
|
||||
_cacheService = cacheService,
|
||||
_loggerService = loggerService;
|
||||
|
||||
OccasionConfigModel _config = OccasionConfigModel.disabled();
|
||||
OccasionConfigModel get config => _config;
|
||||
|
||||
/// True only when enabled AND (if a window is configured) within it.
|
||||
bool get isOccasionActive => _config.enabled && _config.isWithinActiveWindow();
|
||||
|
||||
String? get backgroundOpacityImage => isOccasionActive ? _config.backgroundOpacityImage : null;
|
||||
|
||||
double get backgroundImageOpacity => _config.backgroundImageOpacity;
|
||||
|
||||
/// The occasion's main header/banner image (e.g. National Day greeting
|
||||
/// banner), rendered at full opacity. Null unless an occasion is active.
|
||||
String? get headerImage => isOccasionActive ? _config.headerImage : null;
|
||||
|
||||
/// Call once at app startup (before the first frame) to restore the last
|
||||
/// known-good config instantly (offline-safe), then kick off a remote
|
||||
/// fetch in the background.
|
||||
Future<void> initialize() async {
|
||||
_restoreCachedConfig();
|
||||
_applyToAppColors();
|
||||
|
||||
try {
|
||||
await _remoteConfigService.initialize();
|
||||
await refresh();
|
||||
} catch (ex) {
|
||||
_loggerService.logError('OccasionThemeViewModel.initialize error: $ex');
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches the latest occasion config from Firebase Remote Config and
|
||||
/// applies it. Safe to call again later (e.g. on app resume) to pick up
|
||||
/// changes without requiring an app update.
|
||||
Future<void> refresh() async {
|
||||
try {
|
||||
await _remoteConfigService.fetchAndActivate();
|
||||
final rawJson = _remoteConfigService.occasionConfigRawJson;
|
||||
_config = OccasionConfigModel.fromJson(json.decode(rawJson) as Map<String, dynamic>);
|
||||
await _cacheService.saveString(key: _cachedConfigKey, value: rawJson);
|
||||
_applyToAppColors();
|
||||
notifyListeners();
|
||||
} catch (ex) {
|
||||
_loggerService.logError('OccasionThemeViewModel.refresh error: $ex');
|
||||
// Keep whatever config was already applied (cached/disabled) — never
|
||||
// let a remote-config failure change the app's appearance unexpectedly.
|
||||
}
|
||||
}
|
||||
|
||||
void _restoreCachedConfig() {
|
||||
try {
|
||||
final cachedJson = _cacheService.getString(key: _cachedConfigKey);
|
||||
if (cachedJson == null || cachedJson.isEmpty) {
|
||||
_config = OccasionConfigModel.disabled();
|
||||
return;
|
||||
}
|
||||
_config = OccasionConfigModel.fromJson(json.decode(cachedJson) as Map<String, dynamic>);
|
||||
} catch (ex) {
|
||||
_loggerService.logError('OccasionThemeViewModel._restoreCachedConfig error: $ex');
|
||||
_config = OccasionConfigModel.disabled();
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps AppColors in sync so non-Provider call sites (AppColors.primaryRedColor)
|
||||
/// pick up the occasion color exactly like the existing dark-mode flag does.
|
||||
void _applyToAppColors() {
|
||||
AppColors.isOccasionThemeActive = isOccasionActive;
|
||||
AppColors.occasionPrimaryColor = _config.primaryColor;
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,61 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hmg_patient_app_new/features/occasion_theme/occasion_theme_view_model.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
/// Renders the remotely-configured occasion imagery:
|
||||
/// - `background_opacity_image`: a decorative backdrop/watermark drawn at
|
||||
/// [OccasionThemeViewModel.backgroundImageOpacity].
|
||||
/// - `header_image`: the occasion's main header/banner (e.g. the National
|
||||
/// Day greeting banner), drawn on top at full opacity.
|
||||
///
|
||||
/// Fully data-driven: when no occasion is active (the default), this widget
|
||||
/// renders nothing and takes zero space — the existing layout/UI is
|
||||
/// completely unaffected.
|
||||
class OccasionBannerWidget extends StatelessWidget {
|
||||
const OccasionBannerWidget({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<OccasionThemeViewModel>(
|
||||
builder: (context, occasionThemeVm, _) {
|
||||
final backgroundImageUrl = occasionThemeVm.backgroundOpacityImage;
|
||||
final headerImageUrl = occasionThemeVm.headerImage;
|
||||
final hasBackgroundImage = occasionThemeVm.isOccasionActive && (backgroundImageUrl?.isNotEmpty ?? false);
|
||||
final hasHeaderImage = occasionThemeVm.isOccasionActive && (headerImageUrl?.isNotEmpty ?? false);
|
||||
|
||||
if (!hasBackgroundImage && !hasHeaderImage) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
if (hasBackgroundImage)
|
||||
Opacity(
|
||||
opacity: occasionThemeVm.backgroundImageOpacity.clamp(0.0, 1.0),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: backgroundImageUrl!,
|
||||
width: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => const SizedBox.shrink(),
|
||||
errorWidget: (_, __, ___) => const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
if (hasHeaderImage)
|
||||
CachedNetworkImage(
|
||||
imageUrl: headerImageUrl!,
|
||||
width: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => const SizedBox.shrink(),
|
||||
errorWidget: (_, __, ___) => const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -0,0 +1,84 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:firebase_remote_config/firebase_remote_config.dart';
|
||||
import 'package:flutter/foundation.dart' show kDebugMode;
|
||||
import 'package:flutter/services.dart' show rootBundle;
|
||||
import 'package:hmg_patient_app_new/services/logger_service.dart';
|
||||
|
||||
/// Firebase Remote Config wrapper. Fully generic — the app never hardcodes
|
||||
/// occasion-specific values here; it only knows about the `occasion_config`
|
||||
/// remote key and reads whatever JSON is published on the Firebase console.
|
||||
abstract class RemoteConfigService {
|
||||
/// Must be called once at startup, before the first frame if possible.
|
||||
Future<void> initialize();
|
||||
|
||||
/// Fetches the latest values from Firebase (respecting the configured
|
||||
/// minimum fetch interval) and activates them.
|
||||
Future<bool> fetchAndActivate();
|
||||
|
||||
/// Raw JSON string currently held for the `occasion_config` key
|
||||
/// (bundled default until the first successful fetch/activate).
|
||||
String get occasionConfigRawJson;
|
||||
}
|
||||
|
||||
class RemoteConfigServiceImp implements RemoteConfigService {
|
||||
static const String occasionConfigKey = 'occasion_config';
|
||||
static const String _defaultConfigAssetPath = 'assets/json/default_occasion_config.json';
|
||||
|
||||
final LoggerService loggerService;
|
||||
final FirebaseRemoteConfig _remoteConfig;
|
||||
|
||||
RemoteConfigServiceImp({
|
||||
required this.loggerService,
|
||||
FirebaseRemoteConfig? remoteConfig,
|
||||
}) : _remoteConfig = remoteConfig ?? FirebaseRemoteConfig.instance;
|
||||
|
||||
@override
|
||||
Future<void> initialize() async {
|
||||
try {
|
||||
await _remoteConfig.setConfigSettings(
|
||||
RemoteConfigSettings(
|
||||
fetchTimeout: const Duration(seconds: 10),
|
||||
// In debug builds, always fetch fresh values so you can test
|
||||
// Remote Config changes instantly without waiting/throttling.
|
||||
// Release builds keep a sane 1-hour minimum interval.
|
||||
minimumFetchInterval: kDebugMode ? Duration.zero : const Duration(hours: 1),
|
||||
),
|
||||
);
|
||||
|
||||
// Bundled fallback so behaviour is deterministic (and identical to the
|
||||
// current app look) before the very first successful remote fetch.
|
||||
final defaultJson = await rootBundle.loadString(_defaultConfigAssetPath);
|
||||
await _remoteConfig.setDefaults(<String, dynamic>{occasionConfigKey: defaultJson});
|
||||
} catch (ex) {
|
||||
loggerService.logError('RemoteConfigService.initialize error: $ex');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> fetchAndActivate() async {
|
||||
try {
|
||||
return await _remoteConfig.fetchAndActivate();
|
||||
} catch (ex) {
|
||||
loggerService.logError('RemoteConfigService.fetchAndActivate error: $ex');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String get occasionConfigRawJson {
|
||||
try {
|
||||
final value = _remoteConfig.getString(occasionConfigKey);
|
||||
if (value.isEmpty) return _fallbackDisabledJson;
|
||||
// Validate JSON shape early; if malformed, fall back safely.
|
||||
json.decode(value);
|
||||
return value;
|
||||
} catch (ex) {
|
||||
loggerService.logError('RemoteConfigService.occasionConfigRawJson parse error: $ex');
|
||||
return _fallbackDisabledJson;
|
||||
}
|
||||
}
|
||||
|
||||
static const String _fallbackDisabledJson = '{"key":"none","enabled":false}';
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue