diff --git a/lib/core/models/navigation/navigation_item.dart b/lib/core/models/navigation/navigation_item.dart index eec84c3..fd1c0be 100644 --- a/lib/core/models/navigation/navigation_item.dart +++ b/lib/core/models/navigation/navigation_item.dart @@ -1,11 +1,13 @@ import "package:flutter/material.dart"; +import "package:miutem/core/models/user/perfil.dart"; class NavigationItem { final Widget destination; final String label; final String featureFlag; + final List perfiles; final IconData icon; - NavigationItem({required this.destination, required this.label, required this.featureFlag, required this.icon}); + NavigationItem({required this.destination, required this.label, required this.featureFlag, required this.icon, this.perfiles = const []}); } \ No newline at end of file diff --git a/lib/core/models/user/perfil.dart b/lib/core/models/user/perfil.dart index 45615da..392a89a 100644 --- a/lib/core/models/user/perfil.dart +++ b/lib/core/models/user/perfil.dart @@ -1,7 +1,11 @@ enum Perfil { estudiante, funcionario, - profesor, + profesor; - ; + String get displayName => switch (this) { + Perfil.estudiante => "Estudiante", + Perfil.funcionario => "Funcionario", + Perfil.profesor => "Profesor", + }; } \ No newline at end of file diff --git a/lib/core/services/auth_service.dart b/lib/core/services/auth_service.dart index c0567be..57ddfe1 100644 --- a/lib/core/services/auth_service.dart +++ b/lib/core/services/auth_service.dart @@ -13,6 +13,11 @@ class AuthService { final SecureStorageRepository _secureStorageRepository = Get.find(); bool idHasBeenSet = false; + Estudiante? _cachedEstudiante; + + /// Returns the currently logged-in student from the in-memory cache. + /// Used for synchronous access (e.g. profile-based feature flags). + Estudiante? get cachedEstudiante => _cachedEstudiante; Future isFirstTime() async => (await Preferencia.lastLogin.exists()) == false; @@ -29,6 +34,7 @@ class AuthService { Estudiante? estudiante = await _secureStorageRepository.getEstudiante(); if (estudiante != null && !forceRefresh) { + _cachedEstudiante = estudiante; if(!idHasBeenSet) { setUserIdentifier(estudiante); idHasBeenSet = true; @@ -49,6 +55,7 @@ class AuthService { } estudiante = Estudiante.fromJson(response.data["response"] as Map); + _cachedEstudiante = estudiante; await _secureStorageRepository.setEstudiante(estudiante); await Preferencia.lastLogin.set(DateTime.now().toIso8601String()); if(!idHasBeenSet) { @@ -83,6 +90,7 @@ class AuthService { } Future logout({ BuildContext? context}) async { + _cachedEstudiante = null; await _secureStorageRepository.setEstudiante(null); await _secureStorageRepository.setCredentials(null); await Preferencia.onboardingStep.delete(); diff --git a/lib/screens/asignaturas/lista_asignaturas_screen.dart b/lib/screens/asignaturas/lista_asignaturas_screen.dart index d81e68d..5f4940e 100644 --- a/lib/screens/asignaturas/lista_asignaturas_screen.dart +++ b/lib/screens/asignaturas/lista_asignaturas_screen.dart @@ -2,14 +2,15 @@ import "package:flutter/material.dart"; import "package:get/get.dart"; import "package:miutem/core/models/asignaturas/asignatura.dart"; import "package:miutem/core/models/user/estudiante.dart"; +import "package:miutem/core/models/user/perfil.dart"; import "package:miutem/core/services/asignaturas_service.dart"; import "package:miutem/core/services/auth_service.dart"; -import "package:miutem/core/utils/utils.dart"; +import "package:miutem/core/utils/logger.dart"; import "package:miutem/screens/asignaturas/widgets/acceso_rapido.dart"; import "package:miutem/screens/asignaturas/widgets/asignaturas_en_curso.dart"; import "package:miutem/screens/auth/login/login_screen.dart"; - import "package:miutem/styles/styles.dart"; +import "package:miutem/widgets/feature_flag.dart"; class AsignaturasScreen extends StatefulWidget { const AsignaturasScreen({super.key}); @@ -25,22 +26,24 @@ class _AsignaturasScreenState extends State { @override void initState() { super.initState(); - Get.find() - .login() - .then((estudiante) => setState(() => this.estudiante = estudiante), - onError: (err) { + Get.find().login().then((estudiante) { + if(mounted) setState(() => this.estudiante = estudiante); + + if(!estudiante.perfiles.contains(Perfil.estudiante)) { + return; + } + + Get.find().getAsignaturas().then((asignaturas) { + if(mounted) setState(() => this.asignaturas = asignaturas); + }).catchError((error, stackTrace) { + logger.d("Error loading asignaturas: $error"); + }); + }, onError: (err) { if (mounted) { Navigator.popUntil(context, (route) => route.isFirst); - Navigator.pushReplacement( - context, MaterialPageRoute(builder: (ctx) => const LoginScreen())); + Navigator.pushReplacement(context, MaterialPageRoute(builder: (ctx) => const LoginScreen())); } }); - Get.find() - .getAsignaturas() - .then((asignaturas) => setState(() => this.asignaturas = asignaturas), - onError: (err) { - logger.e("Error al cargar asignaturas", error: err); - }); } @override @@ -50,15 +53,26 @@ class _AsignaturasScreenState extends State { child: RefreshIndicator( onRefresh: () async { setState(() { - this.estudiante = null; - this.asignaturas = null; - }); - final estudiante = await Get.find().login(forceRefresh: true); - final asignaturas = await Get.find().getAsignaturas(forceRefresh: true); - setState(() { - this.estudiante = estudiante; - this.asignaturas = asignaturas; + estudiante = null; + asignaturas = null; }); + try { + final estudiante = await Get.find().login(forceRefresh: true); + final asignaturas = await Get.find().getAsignaturas(forceRefresh: true); + if (!mounted) return; + setState(() { + this.estudiante = estudiante; + this.asignaturas = asignaturas; + }); + } catch (error) { + logger.d("Error refreshing asignaturas: $error"); + if (!mounted) return; + setState(() { + // Keep nulls or existing values; here we leave them null to indicate failure. + estudiante = null; + asignaturas = null; + }); + } }, child: SingleChildScrollView( physics: const AlwaysScrollableScrollPhysics(), @@ -71,7 +85,10 @@ class _AsignaturasScreenState extends State { Space.large, const AccesoRapido(), Space.large, - AsignaturasEnCurso(asignaturas: asignaturas), + FeatureFlag.profiles( + const [Perfil.estudiante], + child: AsignaturasEnCurso(asignaturas: asignaturas), + ), ], ), ), diff --git a/lib/screens/asignaturas/widgets/acceso_rapido.dart b/lib/screens/asignaturas/widgets/acceso_rapido.dart index 3043672..2f2a003 100644 --- a/lib/screens/asignaturas/widgets/acceso_rapido.dart +++ b/lib/screens/asignaturas/widgets/acceso_rapido.dart @@ -1,6 +1,8 @@ import "package:flutter/material.dart"; -import "package:miutem/styles/styles.dart"; +import "package:miutem/core/models/user/perfil.dart"; import "package:miutem/screens/asignaturas/actions/acceso_rapido.dart"; +import "package:miutem/styles/styles.dart"; +import "package:miutem/widgets/feature_flag.dart"; class AccesoRapido extends StatelessWidget { const AccesoRapido({super.key}); @@ -17,12 +19,16 @@ class AccesoRapido extends StatelessWidget { physics: const AlwaysScrollableScrollPhysics(), scrollDirection: Axis.horizontal, children: [ - CardAccesoRapido( - color: AppTheme.lightBlueCard, - colorDark: AppTheme.darkBlueCard, - label: "Horario", - icon: AppIcons.timetable, - onTap: () => visitarHorario(context), + FeatureFlag.profiles( + const [Perfil.estudiante], + showProfileRestrictionMessage: false, + child: CardAccesoRapido( + color: AppTheme.lightBlueCard, + colorDark: AppTheme.darkBlueCard, + label: "Horario", + icon: AppIcons.timetable, + onTap: () => visitarHorario(context), + ), ), HorizontalSpace.extraSmall, CardAccesoRapido( @@ -34,13 +40,17 @@ class AccesoRapido extends StatelessWidget { onTap: () => visitarCalculadoraNotas(context), ), HorizontalSpace.extraSmall, - CardAccesoRapido( - color: AppTheme.lightSalmonCard, - colorDark: AppTheme.darkSalmonCard, - label: "Malla Histórica", - icon: AppIcons.historicTimetable, - fill: 0, - onTap: () => visitarMallaHistorica(context), + FeatureFlag.profiles( + const [Perfil.estudiante], + showProfileRestrictionMessage: false, + child: CardAccesoRapido( + color: AppTheme.lightSalmonCard, + colorDark: AppTheme.darkSalmonCard, + label: "Malla Histórica", + icon: AppIcons.historicTimetable, + fill: 0, + onTap: () => visitarMallaHistorica(context), + ), ), ], ), diff --git a/lib/screens/credencial/credencial_screen.dart b/lib/screens/credencial/credencial_screen.dart index 6084705..5e34dd6 100644 --- a/lib/screens/credencial/credencial_screen.dart +++ b/lib/screens/credencial/credencial_screen.dart @@ -2,6 +2,7 @@ import "package:flutter/material.dart"; import "package:get/get.dart"; import "package:miutem/core/models/user/credencial/credencial_biblioteca.dart"; import "package:miutem/core/models/user/estudiante.dart"; +import "package:miutem/core/models/user/perfil.dart"; import "package:miutem/core/services/auth_service.dart"; import "package:miutem/core/services/mi_utem/miutem_credencial_service.dart"; import "package:miutem/core/utils/utils.dart"; @@ -11,6 +12,7 @@ import "package:miutem/screens/credencial/widgets/credencial_biblioteca_front.da import "package:miutem/screens/credencial/widgets/credencial_front.dart"; import "package:miutem/screens/credencial/widgets/flip_card.dart"; import "package:miutem/styles/styles.dart"; +import "package:miutem/widgets/feature_flag.dart"; enum TipoCredencial { institucional, sibutem } @@ -89,54 +91,62 @@ class _CredencialScreenState extends State { style: Theme.of(context).textTheme.headlineMedium, ), Space.medium, - SizedBox( - width: double.infinity, - child: SegmentedButton( - segments: const [ - ButtonSegment( - value: TipoCredencial.institucional, - label: Text("Institucional"), + FeatureFlag.profiles( + const [Perfil.estudiante, Perfil.profesor], + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: double.infinity, + child: SegmentedButton( + segments: const [ + ButtonSegment( + value: TipoCredencial.institucional, + label: Text("Institucional"), + ), + ButtonSegment( + value: TipoCredencial.sibutem, + label: Text("SIBUTEM"), + ), + ], + selected: {_tipoCredencial}, + onSelectionChanged: _onTipoCredencialChanged, + ), ), - ButtonSegment( - value: TipoCredencial.sibutem, - label: Text("SIBUTEM"), + Space.small, + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: _tipoCredencial == TipoCredencial.institucional ? FlipCard( + key: const ValueKey("institucional"), + front: CredencialFront( + usuario: estudiante, + availableHeight: cardHeight, + ), + back: CredencialBack( + availableHeight: cardHeight, + ), + ) : FlipCard( + key: const ValueKey("sibutem"), + front: CredencialBibliotecaFront( + credencial: credencialBiblioteca, + availableHeight: cardHeight, + ), + back: CredencialBibliotecaBack( + credencial: credencialBiblioteca, + estudiante: estudiante, + availableHeight: cardHeight, + ), + ), + ), + Space.small, + Center( + child: Text("Toca la credencial para voltear", + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), ), ], - selected: {_tipoCredencial}, - onSelectionChanged: _onTipoCredencialChanged, - ), - ), - Space.small, - Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: _tipoCredencial == TipoCredencial.institucional ? FlipCard( - key: const ValueKey("institucional"), - front: CredencialFront( - usuario: estudiante, - availableHeight: cardHeight, - ), - back: CredencialBack( - availableHeight: cardHeight, - ), - ) : FlipCard( - key: const ValueKey("sibutem"), - front: CredencialBibliotecaFront( - credencial: credencialBiblioteca, - availableHeight: cardHeight, - ), - back: CredencialBibliotecaBack( - credencial: credencialBiblioteca, - estudiante: estudiante, - availableHeight: cardHeight, - ), - ), - ), - Space.small, - Center( - child: Text("Toca la credencial para voltear", - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), ), ), ], diff --git a/lib/screens/home/home_screen.dart b/lib/screens/home/home_screen.dart index 9c4d037..1c9f0a3 100644 --- a/lib/screens/home/home_screen.dart +++ b/lib/screens/home/home_screen.dart @@ -2,6 +2,7 @@ import "package:flutter/material.dart"; import "package:get/get.dart"; import "package:miutem/core/models/horario.dart"; import "package:miutem/core/models/user/estudiante.dart"; +import "package:miutem/core/models/user/perfil.dart"; import "package:miutem/core/services/auth_service.dart"; import "package:miutem/core/services/firebase/remote_config_service.dart"; import "package:miutem/core/utils/http/http_client.dart"; @@ -13,6 +14,7 @@ import "package:miutem/screens/home/widgets/clases_de_hoy/seccion_clases_de_hoy. import "package:miutem/screens/home/widgets/novedades/card_novedades.dart"; import "package:miutem/screens/home/widgets/saludo.dart"; import "package:miutem/styles/styles.dart"; +import "package:miutem/widgets/feature_flag.dart"; class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); @@ -95,10 +97,13 @@ class _HomeScreenState extends State { ), ), Space.large, - SeccionClasesDeHoy( - errorAlCargarHorario: errorAlCargarHorario, - bloques: bloques, - cargarHorario: _cargarHorario, + FeatureFlag.profiles( + const [Perfil.estudiante], + child: SeccionClasesDeHoy( + errorAlCargarHorario: errorAlCargarHorario, + bloques: bloques, + cargarHorario: _cargarHorario, + ), ), ], ), @@ -108,6 +113,10 @@ class _HomeScreenState extends State { ); Future _cargarHorario({bool forceRefresh = false}) async { + if (estudiante == null || !estudiante!.perfiles.contains(Perfil.estudiante)) { + return; + } + setState(() { errorAlCargarHorario = null; bloques = null; diff --git a/lib/screens/home/widgets/acceso_rapido.dart b/lib/screens/home/widgets/acceso_rapido.dart index 7c6fbb4..f0d7838 100644 --- a/lib/screens/home/widgets/acceso_rapido.dart +++ b/lib/screens/home/widgets/acceso_rapido.dart @@ -1,6 +1,8 @@ import "package:flutter/material.dart"; +import "package:miutem/core/models/user/perfil.dart"; import "package:miutem/screens/home/actions/acceso_rapido.dart"; import "package:miutem/styles/styles.dart"; +import "package:miutem/widgets/feature_flag.dart"; class AccesoRapido extends StatelessWidget { const AccesoRapido({super.key}); @@ -17,12 +19,16 @@ class AccesoRapido extends StatelessWidget { physics: const AlwaysScrollableScrollPhysics(), scrollDirection: Axis.horizontal, children: [ - CardAccesoRapido( - color: AppTheme.lightBlueCard, - colorDark: AppTheme.darkBlueCard, - label: "Horario", - icon: AppIcons.timetable, - onTap: () => visitarHorario(context), + FeatureFlag.profiles( + const [Perfil.estudiante], + showProfileRestrictionMessage: false, + child: CardAccesoRapido( + color: AppTheme.lightBlueCard, + colorDark: AppTheme.darkBlueCard, + label: "Horario", + icon: AppIcons.timetable, + onTap: () => visitarHorario(context), + ), ), HorizontalSpace.extraSmall, CardAccesoRapido( diff --git a/lib/screens/notas/notas_screen.dart b/lib/screens/notas/notas_screen.dart index 64036c4..08664ab 100644 --- a/lib/screens/notas/notas_screen.dart +++ b/lib/screens/notas/notas_screen.dart @@ -3,7 +3,9 @@ import "package:get/get.dart"; import "package:miutem/core/models/asignaturas/asignatura.dart"; import "package:miutem/core/models/evaluacion/evaluacion.dart"; import "package:miutem/core/models/evaluacion/grades.dart"; +import "package:miutem/core/models/user/perfil.dart"; import "package:miutem/core/models/user/persona/persona.dart"; +import "package:miutem/core/services/auth_service.dart"; import "package:miutem/core/services/controllers/notas_controller.dart"; import "package:miutem/screens/notas/actions/cargar_asignaturas_con_notas.dart"; import "package:miutem/screens/notas/widgets/notas.dart"; @@ -45,23 +47,29 @@ class _NotasScreenState extends State { return; } - cargarAsignaturasConNotas().then((asignaturas) { - asignaturas = [ - emptyAsignatura, - ...asignaturas - ]; - if(!mounted) return; - setState(() { - this.asignaturas = asignaturas; - asignatura = emptyAsignatura; - notasController.updateWithGrades(asignatura?.grades); - }); - }, onError: (err) { - if(!mounted) return; - setState(() { - asignaturas = [emptyAsignatura]; - asignatura = emptyAsignatura; - notasController.updateWithGrades(asignatura?.grades); + Get.find().login().then((usuario) { + if(!usuario.perfiles.contains(Perfil.estudiante)){ + return; + } + + cargarAsignaturasConNotas().then((asignaturas) { + asignaturas = [ + emptyAsignatura, + ...asignaturas + ]; + if(!mounted) return; + setState(() { + this.asignaturas = asignaturas; + asignatura = emptyAsignatura; + notasController.updateWithGrades(asignatura?.grades); + }); + }, onError: (err) { + if(!mounted) return; + setState(() { + asignaturas = [emptyAsignatura]; + asignatura = emptyAsignatura; + notasController.updateWithGrades(asignatura?.grades); + }); }); }); } diff --git a/lib/screens/profile/profile_screen.dart b/lib/screens/profile/profile_screen.dart index 611b760..950d068 100644 --- a/lib/screens/profile/profile_screen.dart +++ b/lib/screens/profile/profile_screen.dart @@ -38,7 +38,7 @@ class _ProfileScreenState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.start, children: [ - if (estudiante != null) ProfileHeader(estudiante: estudiante), + if (estudiante != null) ProfileHeader(usuario: estudiante), Space.extraSmall, const LogOutButton(), Space.small, diff --git a/lib/screens/profile/widgets/profile_header.dart b/lib/screens/profile/widgets/profile_header.dart index 48017d0..a6d6109 100644 --- a/lib/screens/profile/widgets/profile_header.dart +++ b/lib/screens/profile/widgets/profile_header.dart @@ -2,17 +2,19 @@ import "package:flutter/material.dart"; import "package:get/get.dart"; import "package:miutem/core/models/carrera.dart"; import "package:miutem/core/models/user/estudiante.dart"; +import "package:miutem/core/models/user/perfil.dart"; import "package:miutem/core/services/carrera_service.dart"; import "package:miutem/core/utils/utils.dart"; import "package:miutem/styles/styles.dart"; +import "package:miutem/widgets/feature_flag.dart"; import "package:miutem/widgets/user_avatar.dart"; import "package:skeletonizer/skeletonizer.dart"; import "package:logger/logger.dart"; class ProfileHeader extends StatefulWidget { - final Estudiante? estudiante; + final Estudiante? usuario; - const ProfileHeader({super.key, required this.estudiante}); + const ProfileHeader({super.key, required this.usuario}); @override State createState() => _ProfileHeaderState(); @@ -37,7 +39,7 @@ class _ProfileHeaderState extends State { } Future _loadCarrera() async { - if (widget.estudiante != null) { + if (widget.usuario?.perfiles.contains(Perfil.estudiante) == true) { try { final loadedCarrera = await Get.find().getCarrera(); if (mounted) { @@ -52,7 +54,7 @@ class _ProfileHeaderState extends State { @override void didUpdateWidget(ProfileHeader oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.estudiante != widget.estudiante) { + if (oldWidget.usuario != widget.usuario) { _loadCarrera(); } } @@ -64,43 +66,47 @@ class _ProfileHeaderState extends State { child: Column( children: [ Skeletonizer( - enabled: widget.estudiante == null, + enabled: widget.usuario == null, child: UserAvatar( - estudiante: widget.estudiante, + estudiante: widget.usuario, radius: 50, ), ), Space.small, Skeletonizer( - enabled: widget.estudiante == null, - child: Text(widget.estudiante?.primerNombre ?? "John", + enabled: widget.usuario == null, + child: Text(widget.usuario?.primerNombre ?? "John", style: Theme.of(context).textTheme.headlineMedium, ), ), Space.extraSmall, Skeletonizer( - enabled: widget.estudiante == null, - child: Text(capitalize(widget.estudiante?.nombreCompleto ?? "John Doe"), + enabled: widget.usuario == null, + child: Text(capitalize(widget.usuario?.nombreCompleto ?? "John Doe"), style: Theme.of(context).textTheme.labelMedium, ), ), Space.extraSmall, Skeletonizer( - enabled: widget.estudiante == null, - child: Text((widget.estudiante?.correoUtem ?? "correo@utem.cl").toLowerCase(), + enabled: widget.usuario == null, + child: Text((widget.usuario?.correoUtem ?? "correo@utem.cl").toLowerCase(), style: Theme.of(context).textTheme.bodyLarge ), ), Space.extraSmall, - Skeletonizer( - enabled: carrera == null, - child: SizedBox( - height: 40, - child: Text(carrera?.nombre ?? "Carrera\nen Curso", - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyLarge, - maxLines: 2, - overflow: TextOverflow.ellipsis, + FeatureFlag.profiles( + const [Perfil.estudiante], + showProfileRestrictionMessage: false, + child: Skeletonizer( + enabled: carrera == null, + child: SizedBox( + height: 40, + child: Text(carrera?.nombre ?? "Carrera\nen Curso", + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), ), ), ), diff --git a/lib/styles/navigation/bottom_navbar.dart b/lib/styles/navigation/bottom_navbar.dart index fdb287c..fa0c003 100644 --- a/lib/styles/navigation/bottom_navbar.dart +++ b/lib/styles/navigation/bottom_navbar.dart @@ -1,5 +1,6 @@ import "package:flutter/material.dart"; import "package:miutem/core/models/navigation/navigation_item.dart"; +import "package:miutem/core/models/user/perfil.dart"; import "package:miutem/screens/asignaturas/lista_asignaturas_screen.dart"; import "package:miutem/screens/credencial/credencial_screen.dart"; import "package:miutem/screens/home/home_screen.dart"; @@ -20,14 +21,14 @@ class _BottomNavBarState extends State { int idx = 0; final List allScreens = [ NavigationItem(destination: const HomeScreen(), label: "Inicio", featureFlag: "bottom_navigation.home", icon: AppIcons.home), - NavigationItem(destination: const AsignaturasScreen(), label: "Asignaturas", featureFlag: "bottom_navigation.asignaturas", icon: AppIcons.subjects), + NavigationItem(destination: const AsignaturasScreen(), label: "Asignaturas", featureFlag: "bottom_navigation.asignaturas", icon: AppIcons.subjects, perfiles: [Perfil.estudiante]), NavigationItem(destination: const CredencialScreen(), label: "Credencial", featureFlag: "bottom_navigation.credencial", icon: AppIcons.credential), NavigationItem(destination: const TaskListScreen(), label: "Apuntes", featureFlag: "bottom_navigation.apuntes", icon: AppIcons.notes), NavigationItem(destination: const ProfileScreen(), label: "Perfil", featureFlag: "bottom_navigation.perfil", icon: AppIcons.profile), ]; List get enabledScreens { - return allScreens.where((screen) => FeatureFlag.evaluateSync(screen.featureFlag)).toList(); + return allScreens.where((screen) => FeatureFlag.evaluateSync(screen.featureFlag)).where((screen) => screen.perfiles.isNotEmpty ? FeatureFlag.evaluateProfileSync(screen.perfiles) : true).toList(); } @override diff --git a/lib/widgets/feature_flag.dart b/lib/widgets/feature_flag.dart index 283d554..360231f 100644 --- a/lib/widgets/feature_flag.dart +++ b/lib/widgets/feature_flag.dart @@ -3,6 +3,8 @@ import "package:firebase_remote_config/firebase_remote_config.dart"; import "package:flutter/foundation.dart"; import "package:flutter/material.dart"; import "package:get/get.dart"; +import "package:miutem/core/models/user/perfil.dart"; +import "package:miutem/core/services/auth_service.dart"; import "package:miutem/core/services/firebase/keys.dart"; import "package:miutem/core/services/firebase/remote_config_service.dart"; import "package:miutem/core/utils/utils.dart"; @@ -25,12 +27,21 @@ class FeatureFlag extends StatelessWidget { /// Multiple feature flag keys (at least one must be true to show the child) final List? flagKeys; + /// Profiles allowed to see this feature. If the current user's profile is not + /// in this list, a "Característica disponible solo para perfiles: ..." message + /// is shown instead of the child. + final List? allowedProfiles; + /// The child widget to render if the feature flag(s) is/are enabled final Widget child; /// The fallback widget to render if the feature flag(s) is/are disabled (optional) final Widget? fallback; + /// Whether to show the default profile restriction message when the user's + /// profile is not in [allowedProfiles]. When false, renders nothing instead. + final bool showProfileRestrictionMessage; + /// Whether to show debug information in development mode final bool showDebugInfo; @@ -38,6 +49,8 @@ class FeatureFlag extends StatelessWidget { super.key, required this.child, this.fallback, + this.allowedProfiles, + this.showProfileRestrictionMessage = true, this.showDebugInfo = false, }) : flagKeys = null; @@ -46,9 +59,21 @@ class FeatureFlag extends StatelessWidget { super.key, required this.child, this.fallback, + this.allowedProfiles, + this.showProfileRestrictionMessage = true, this.showDebugInfo = false, }) : flagKey = null; + /// Constructor for profile-only restriction (no Firebase Remote Config flag needed) + const FeatureFlag.profiles( + List profiles, { + super.key, + required this.child, + this.fallback, + this.showProfileRestrictionMessage = false, + this.showDebugInfo = false, + }) : flagKey = null, flagKeys = null, allowedProfiles = profiles; + /// Evaluates a feature flag and returns its boolean value /// /// Usage: @@ -271,37 +296,121 @@ class FeatureFlag extends StatelessWidget { } } + /// Evaluates whether the current user's profile is in the allowed profiles list. + /// + /// - [allowedProfiles] must be a non-empty list of profiles that are allowed to see + /// the gated content. + /// - Returns `true` if the current user has at least one of the allowed profiles. + /// - Returns `false` if the user cannot be determined, has no profiles, or an + /// error occurs (fail-closed during loading/errors). + static bool evaluateProfileSync(List allowedProfiles) { + try { + final authService = Get.find(); + final currentProfiles = authService.cachedEstudiante?.perfiles ?? []; + if (currentProfiles.isEmpty) return false; + return currentProfiles.any((p) => allowedProfiles.contains(p)); + } catch (e) { + debugPrint("FeatureFlag: Error evaluating profile restriction: $e"); + return false; + } + } + @override Widget build(BuildContext context) { - final isEnabled = _isAnyFlagEnabled(); - final debugInfo = _getDebugInfo(); + // Check feature flag first (existing behavior) + if (flagKey != null || flagKeys != null) { + final isEnabled = _isAnyFlagEnabled(); + + if (showDebugInfo && kDebugMode) { + final debugInfo = _getDebugInfo(); + + // Apply the same profile restriction logic used in the non-debug path + Widget gatedChild; + if (!isEnabled) { + // Feature flag disabled: use the same fallback logic as below + gatedChild = fallback ?? const SizedBox.shrink(); + } else { + // Feature flag enabled: enforce profile restrictions if configured + if (allowedProfiles != null && allowedProfiles!.isNotEmpty && !evaluateProfileSync(allowedProfiles!)) { + if (fallback != null) { + gatedChild = fallback!; + } else if (showProfileRestrictionMessage) { + gatedChild = _buildProfileRestrictedFallback(context); + } else { + gatedChild = const SizedBox.shrink(); + } + } else { + gatedChild = child; + } + } - if (showDebugInfo && kDebugMode) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: isEnabled ? Colors.green.withValues(alpha: 0.2) : Colors.red.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(4), + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: isEnabled ? Colors.green.withValues(alpha: 0.2) : Colors.red.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + debugInfo, + style: TextStyle( + fontSize: 10, + color: isEnabled ? Colors.green[800] : Colors.red[800], + fontFamily: "monospace", + ), + ), ), + const SizedBox(height: 4), + gatedChild, + ], + ); + } + + if (!isEnabled) return fallback ?? const SizedBox.shrink(); + } + + // Check profile restriction + if (allowedProfiles != null && allowedProfiles!.isNotEmpty) { + if (!evaluateProfileSync(allowedProfiles!)) { + if (fallback != null) return fallback!; + if (showProfileRestrictionMessage) return _buildProfileRestrictedFallback(context); + return const SizedBox.shrink(); + } + } + + return child; + } + + Widget _buildProfileRestrictedFallback(BuildContext context) { + final profileNames = allowedProfiles!.map((p) => p.displayName).join(", "); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Theme.of(context).colorScheme.outlineVariant), + ), + child: Row( + children: [ + Icon( + Icons.lock_outline_rounded, + size: 16, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Expanded( child: Text( - debugInfo, - style: TextStyle( - fontSize: 10, - color: isEnabled ? Colors.green[800] : Colors.red[800], - fontFamily: "monospace", + "Característica disponible solo para perfiles: $profileNames", + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ), - const SizedBox(height: 4), - if (isEnabled) child else (fallback ?? const SizedBox.shrink()), ], - ); - } - - return isEnabled ? child : (fallback ?? const SizedBox.shrink()); + ), + ); } /// Checks if at least one flag is enabled (for multiple flags) or the flag is enabled (for single flag)