diff --git a/README.md b/README.md
index b134ded..621a609 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@
## Descripción
-Univalle App nació con el objetivo de centralizar en un solo lugar los distintos servicios que la Universidad del Valle expone hoy en sistemas separados (SIRA, U-Planner, el restaurante universitario, la biblioteca OPAC, evaluación docente), cada uno con su propia sesión y su propia forma de pedir la información. La app se conecta a cada uno de esos sistemas y traduce lo que obtiene a un lenguaje de dominio propio, para que la persona usuaria tenga una sola experiencia consistente sin lidiar con SIRA ni con sus formularios.
+Univalle App nació con el objetivo de centralizar en un solo lugar los distintos servicios que la Universidad del Valle expone hoy en sistemas separados (SIRA, U-Planner, el restaurante universitario, la biblioteca OPAC, evaluación docente, la Agencia de Noticias), cada uno con su propia sesión y su propia forma de pedir la información. La app se conecta a cada uno de esos sistemas y traduce lo que obtiene a un lenguaje de dominio propio, para que la persona usuaria tenga una sola experiencia consistente sin lidiar con SIRA ni con sus formularios.
## Arquitectura
@@ -73,6 +73,7 @@ lib/
├── schedule/ # Horario de clases
├── teaching_rating/ # Evaluación docente
├── digital_card/ # Carné estudiantil digital
+ ├── news/ # Noticias de la Agencia de Noticias Univalle
├── restaurant/ # Tiquetes de almuerzo (en desarrollo)
└── library/ # Cuenta de biblioteca (en desarrollo)
```
@@ -100,6 +101,7 @@ flowchart LR
REST["Restaurante universitario
(tiquetes de almuerzo)"]
LIB["Biblioteca OPAC
(cuenta, préstamos)"]
EVAL["Evaluación docente"]
+ NEWS["Agencia de Noticias Univalle
(noticias)"]
App --> SIRA
App --> SIRA1
@@ -108,6 +110,7 @@ flowchart LR
App --> REST
App --> LIB
App --> EVAL
+ App --> NEWS
```
Y así se ve una petición típica, de punta a punta:
@@ -132,7 +135,7 @@ sequenceDiagram
VM-->>U: AsyncValue renderizado
```
-De los 7 sistemas externos, U-Planner es la única API JSON real; el resto se scrapea desde HTML, en varios casos en Latin-1 y con un parsing bastante posicional, porque cada sistema expone la información de forma distinta.
+De los 8 sistemas externos, U-Planner es la única API JSON real; el resto se scrapea desde HTML, en varios casos en Latin-1 y con un parsing bastante posicional, porque cada sistema expone la información de forma distinta.
## Funcionalidades
@@ -146,6 +149,7 @@ De los 7 sistemas externos, U-Planner es la única API JSON real; el resto se sc
| Horario de clases | ✅ |
| Calificar docentes | ✅ |
| Carné estudiantil digital | ✅ |
+| Noticias de la Agencia de Noticias Univalle | ✅ |
| Restaurante universitario (tiquetes de almuerzo) | 🔧 En desarrollo |
| Biblioteca (cuenta, préstamos) | 🔧 En desarrollo |
| Enlaces de interés | ❌ Pendiente |
@@ -180,9 +184,17 @@ El proyecto usa Riverpod con codegen, así que hace falta generar el código (`*
```bash
dart run build_runner build --delete-conflicting-outputs
-flutter run
```
+La app lee la URL del backend de configuración remota (`lib/core/constants/api_constants.dart`) desde la variable de entorno `API_BASE_URL`, así que hay que pasarla con `--dart-define` al correr o compilar:
+
+```bash
+flutter run --dart-define=API_BASE_URL=https://tu-backend/api
+```
+
+> [!NOTE]
+> Si corrés desde VS Code, configurá el mismo `--dart-define` como `toolArgs` en `.vscode/launch.json` (no versionado) para no tener que pasarlo a mano en cada debug/run.
+
## Compilación
```bash
diff --git a/lib/core/constants/api_constants.dart b/lib/core/constants/api_constants.dart
new file mode 100644
index 0000000..74076e1
--- /dev/null
+++ b/lib/core/constants/api_constants.dart
@@ -0,0 +1,10 @@
+abstract final class ApiConstants {
+ static const String baseUrl = String.fromEnvironment(
+ 'API_BASE_URL',
+ defaultValue: 'http://10.0.2.2:3000',
+ );
+ static const String configPath = '/app/config';
+ static const String modulesPath = '/app/modules';
+ static const String welcomePath = '/app/welcome';
+ static const String announcementsPath = '/app/announcements';
+}
diff --git a/lib/core/device/app_platform_provider.dart b/lib/core/device/app_platform_provider.dart
new file mode 100644
index 0000000..d07776e
--- /dev/null
+++ b/lib/core/device/app_platform_provider.dart
@@ -0,0 +1,15 @@
+import 'package:flutter/foundation.dart';
+import 'package:riverpod_annotation/riverpod_annotation.dart';
+
+part 'app_platform_provider.g.dart';
+
+/// The platform key the backend expects for `GET /app/config` ('ios' or
+/// 'android'). Derived from [defaultTargetPlatform] rather than `dart:io`
+/// so it stays overridable in tests.
+@riverpod
+String appPlatform(Ref ref) {
+ return switch (defaultTargetPlatform) {
+ TargetPlatform.iOS => 'ios',
+ _ => 'android',
+ };
+}
diff --git a/lib/features/home/presentation/providers/app_version_provider.dart b/lib/core/device/app_version_provider.dart
similarity index 100%
rename from lib/features/home/presentation/providers/app_version_provider.dart
rename to lib/core/device/app_version_provider.dart
diff --git a/lib/core/network/api_dio_provider.dart b/lib/core/network/api_dio_provider.dart
new file mode 100644
index 0000000..15b27b9
--- /dev/null
+++ b/lib/core/network/api_dio_provider.dart
@@ -0,0 +1,17 @@
+import 'package:dio/dio.dart';
+import 'package:riverpod_annotation/riverpod_annotation.dart';
+
+import '../constants/api_constants.dart';
+
+part 'api_dio_provider.g.dart';
+
+@Riverpod(keepAlive: true)
+Dio apiDio(Ref ref) {
+ return Dio(
+ BaseOptions(
+ baseUrl: ApiConstants.baseUrl,
+ connectTimeout: const Duration(seconds: 15),
+ receiveTimeout: const Duration(seconds: 30),
+ ),
+ );
+}
diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart
index 315bc9c..2f6098e 100644
--- a/lib/core/router/app_router.dart
+++ b/lib/core/router/app_router.dart
@@ -9,6 +9,7 @@ import '../../features/home/presentation/views/home_view.dart';
import '../../features/library/presentation/views/library_view.dart';
import '../../features/news/presentation/views/news_view.dart';
import '../../features/profile/presentation/views/profile_view.dart';
+import '../../features/remote_config/presentation/views/announcements_view.dart';
import '../../features/resolution/presentation/views/resolution_view.dart';
import '../../features/restaurant/presentation/views/pending_payment_view.dart';
import '../../features/restaurant/presentation/views/restaurant_view.dart';
@@ -92,6 +93,10 @@ GoRouter appRouter(Ref ref) {
path: AppRoutes.allFunctionalities,
builder: (context, state) => const AllShortcutsView(),
),
+ GoRoute(
+ path: AppRoutes.announcements,
+ builder: (context, state) => const AnnouncementsView(),
+ ),
],
);
}
diff --git a/lib/core/router/app_routes.dart b/lib/core/router/app_routes.dart
index 14475e7..5595798 100644
--- a/lib/core/router/app_routes.dart
+++ b/lib/core/router/app_routes.dart
@@ -13,6 +13,7 @@ abstract final class AppRoutes {
static const restaurant = '/restaurant';
static const library = '/library';
static const news = '/news';
+ static const announcements = '/announcements';
static const pendingPayment = '/restaurant/pending-payment';
static const allFunctionalities = '/functionalities';
}
diff --git a/lib/core/session/current_photo_url_provider.dart b/lib/core/session/current_photo_url_provider.dart
index 798ffb9..154d648 100644
--- a/lib/core/session/current_photo_url_provider.dart
+++ b/lib/core/session/current_photo_url_provider.dart
@@ -1,3 +1,4 @@
+import 'package:flutter/painting.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'current_photo_url_provider.g.dart';
@@ -9,6 +10,10 @@ class CurrentPhotoUrl extends _$CurrentPhotoUrl {
@override
String? build() => null;
- void set(String? photoUrl) => state = photoUrl;
+ void set(String? photoUrl) {
+ if (photoUrl != null) NetworkImage(photoUrl).evict();
+ state = photoUrl;
+ }
+
void clear() => state = null;
}
diff --git a/lib/features/digital_card/presentation/widgets/student_id_card/id_card_header.dart b/lib/features/digital_card/presentation/widgets/student_id_card/id_card_header.dart
index 3a2a9de..3ded453 100644
--- a/lib/features/digital_card/presentation/widgets/student_id_card/id_card_header.dart
+++ b/lib/features/digital_card/presentation/widgets/student_id_card/id_card_header.dart
@@ -96,14 +96,17 @@ class IdCardHeader extends StatelessWidget {
fontWeight: FontWeight.bold,
),
),
- const SizedBox(height: 2),
- Text(
- student.programName,
- textAlign: TextAlign.center,
- maxLines: 2,
- overflow: TextOverflow.ellipsis,
- style: textTheme.bodySmall?.copyWith(
- color: colorScheme.onPrimary.withValues(alpha: 0.85),
+ const SizedBox(width: AppSpacing.sm),
+ SizedBox(
+ width: double.infinity,
+ child: Text(
+ student.programName,
+ textAlign: TextAlign.center,
+ maxLines: 3,
+ overflow: TextOverflow.ellipsis,
+ style: textTheme.bodySmall?.copyWith(
+ color: colorScheme.onPrimary.withValues(alpha: 0.85),
+ ),
),
),
],
diff --git a/lib/features/home/home_strings.dart b/lib/features/home/home_strings.dart
index ce0c255..442f34c 100644
--- a/lib/features/home/home_strings.dart
+++ b/lib/features/home/home_strings.dart
@@ -20,6 +20,8 @@ abstract final class HomeStrings {
static const restaurantShortcut = 'Central (Meléndez)';
static const newsShortcut = 'Noticias';
static const comingSoon = 'Muy pronto podrás usar esta función.';
+ static const announcementsTitle = 'Novedades';
+ static const announcementsEmptyMessage = 'Todavía no hay novedades.';
static const allFunctionalitiesTitle = 'Todas las funcionalidades';
static const repositoryLabel = 'code3743/univalle_app';
static const repositoryUrl = 'https://github.com/code3743/univalle_app';
diff --git a/lib/features/home/presentation/views/all_shortcuts_view.dart b/lib/features/home/presentation/views/all_shortcuts_view.dart
index 9513bdb..91f3ef1 100644
--- a/lib/features/home/presentation/views/all_shortcuts_view.dart
+++ b/lib/features/home/presentation/views/all_shortcuts_view.dart
@@ -1,16 +1,20 @@
import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/theme/app_spacing.dart';
import '../../../../core/widgets/app_scaffold.dart';
import '../../../../core/widgets/shortcut_card.dart';
+import '../../../remote_config/presentation/viewmodels/remote_config_view_model.dart';
import '../../home_strings.dart';
import '../widgets/quick_access_items.dart';
-class AllShortcutsView extends StatelessWidget {
+class AllShortcutsView extends ConsumerWidget {
const AllShortcutsView({super.key});
@override
- Widget build(BuildContext context) {
+ Widget build(BuildContext context, WidgetRef ref) {
+ final config = ref.watch(remoteConfigViewModelProvider).value;
+
return AppScaffold(
title: HomeStrings.allFunctionalitiesTitle,
scrollable: false,
@@ -19,7 +23,7 @@ class AllShortcutsView extends StatelessWidget {
mainAxisSpacing: AppSpacing.md,
crossAxisSpacing: AppSpacing.md,
childAspectRatio: 0.85,
- children: quickAccessItems(context)
+ children: quickAccessItems(context, config)
.map(
(item) => ShortcutCard(
iconAsset: item.iconAsset,
diff --git a/lib/features/home/presentation/views/home_view.dart b/lib/features/home/presentation/views/home_view.dart
index 953b642..32faeb3 100644
--- a/lib/features/home/presentation/views/home_view.dart
+++ b/lib/features/home/presentation/views/home_view.dart
@@ -4,27 +4,24 @@ import 'package:go_router/go_router.dart';
import 'package:univalle_app/core/widgets/app_scaffold.dart';
import '../../../../core/constants/asset_paths.dart';
-import '../../../../core/extensions/snackbar_extension.dart';
import '../../../../core/router/app_routes.dart';
import '../../../../core/session/current_photo_url_provider.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/theme/app_spacing.dart';
import '../../../../core/utils/name_formatter.dart';
import '../../../../core/widgets/async_value_widget.dart';
-import '../../../../core/widgets/shortcut_card.dart';
import '../../../../core/widgets/stat_card.dart';
import '../../../auth/presentation/viewmodels/auth_view_model.dart';
import '../../../profile/presentation/viewmodels/profile_view_model.dart';
+import '../../../remote_config/presentation/viewmodels/remote_config_view_model.dart';
+import '../../../remote_config/presentation/widgets/home_overlays.dart';
import '../../home_strings.dart';
import '../providers/latest_semester_provider.dart';
import '../widgets/home_footer.dart';
+import '../widgets/home_greeting.dart';
+import '../widgets/home_quick_access.dart';
import '../widgets/home_top_bar.dart';
import '../widgets/program_card.dart';
-import '../widgets/quick_access_items.dart';
-
-/// Number of quick-access shortcuts shown on Home; the rest are only
-/// reachable from the "Ver todos" screen.
-const _homeQuickAccessCount = 6;
class HomeView extends ConsumerWidget {
const HomeView({super.key});
@@ -42,12 +39,11 @@ class HomeView extends ConsumerWidget {
final profileState = ref.watch(profileViewModelProvider);
final latestSemester = ref.watch(latestSemesterProvider);
final photoUrl = ref.watch(currentPhotoUrlProvider);
- final colorScheme = Theme.of(context).colorScheme;
-
- void showComingSoon() => context.showSnack(HomeStrings.comingSoon);
+ final config = ref.watch(remoteConfigViewModelProvider).value;
void goToProfile() => context.push(AppRoutes.profile);
void goToAllShortcuts() => context.push(AppRoutes.allFunctionalities);
+ void goToAnnouncements() => context.push(AppRoutes.announcements);
return AppScaffold(
body: AsyncValueWidget(
@@ -56,28 +52,15 @@ class HomeView extends ConsumerWidget {
data: (student) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
+ HomeOverlays(config: config),
HomeTopBar(
- onNotifications: showComingSoon,
+ onNotifications: goToAnnouncements,
onAvatarTap: goToProfile,
initial: NameFormatter.initial(student.firstName),
photoUrl: photoUrl,
),
const SizedBox(height: AppSpacing.lg),
- Text(
- HomeStrings.greeting,
- style: Theme.of(context).textTheme.titleMedium,
- ),
- Text(
- '${NameFormatter.firstName(student.firstName)} 👋',
- style: Theme.of(context).textTheme.headlineMedium
- ?.copyWith(fontWeight: FontWeight.bold),
- ),
- const SizedBox(height: 4),
- Text(
- HomeStrings.tagline,
- style: Theme.of(context).textTheme.bodyMedium
- ?.copyWith(color: colorScheme.onSurfaceVariant),
- ),
+ HomeGreeting(firstName: NameFormatter.firstName(student.firstName)),
const SizedBox(height: AppSpacing.lg),
ProgramCard(
student: student,
@@ -108,44 +91,7 @@ class HomeView extends ConsumerWidget {
],
),
const SizedBox(height: AppSpacing.lg),
- Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- Text(
- HomeStrings.quickAccess,
- style: Theme.of(context).textTheme.titleMedium,
- ),
- TextButton(
- onPressed: goToAllShortcuts,
- child: Row(
- children: [
- Text(HomeStrings.viewAll),
- const Icon(Icons.chevron_right, size: 18),
- ],
- ),
- ),
- ],
- ),
- const SizedBox(height: AppSpacing.sm),
- GridView.count(
- crossAxisCount: 3,
- shrinkWrap: true,
- physics: const NeverScrollableScrollPhysics(),
- mainAxisSpacing: AppSpacing.md,
- crossAxisSpacing: AppSpacing.md,
- childAspectRatio: 0.85,
- children: quickAccessItems(context)
- .take(_homeQuickAccessCount)
- .map(
- (item) => ShortcutCard(
- iconAsset: item.iconAsset,
- label: item.label,
- accent: item.accent,
- onTap: item.onTap,
- ),
- )
- .toList(),
- ),
+ HomeQuickAccess(config: config, onViewAll: goToAllShortcuts),
const SizedBox(height: AppSpacing.lg),
const HomeFooter(),
],
diff --git a/lib/features/home/presentation/widgets/home_footer.dart b/lib/features/home/presentation/widgets/home_footer.dart
index 80f4386..414675c 100644
--- a/lib/features/home/presentation/widgets/home_footer.dart
+++ b/lib/features/home/presentation/widgets/home_footer.dart
@@ -4,9 +4,9 @@ import 'package:flutter_svg/flutter_svg.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../../../core/constants/asset_paths.dart';
+import '../../../../core/device/app_version_provider.dart';
import '../../../../core/extensions/snackbar_extension.dart';
import '../../home_strings.dart';
-import '../providers/app_version_provider.dart';
class HomeFooter extends ConsumerWidget {
const HomeFooter({super.key});
diff --git a/lib/features/home/presentation/widgets/home_greeting.dart b/lib/features/home/presentation/widgets/home_greeting.dart
new file mode 100644
index 0000000..42b1ca5
--- /dev/null
+++ b/lib/features/home/presentation/widgets/home_greeting.dart
@@ -0,0 +1,34 @@
+import 'package:flutter/material.dart';
+
+import '../../home_strings.dart';
+
+class HomeGreeting extends StatelessWidget {
+ const HomeGreeting({super.key, required this.firstName});
+
+ final String firstName;
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ HomeStrings.greeting,
+ style: Theme.of(context).textTheme.titleMedium,
+ ),
+ Text(
+ '$firstName 👋',
+ style: Theme.of(context).textTheme.headlineMedium
+ ?.copyWith(fontWeight: FontWeight.bold),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ HomeStrings.tagline,
+ style: Theme.of(context).textTheme.bodyMedium
+ ?.copyWith(color: colorScheme.onSurfaceVariant),
+ ),
+ ],
+ );
+ }
+}
diff --git a/lib/features/home/presentation/widgets/home_quick_access.dart b/lib/features/home/presentation/widgets/home_quick_access.dart
new file mode 100644
index 0000000..52199ff
--- /dev/null
+++ b/lib/features/home/presentation/widgets/home_quick_access.dart
@@ -0,0 +1,69 @@
+import 'package:flutter/material.dart';
+
+import '../../../../core/theme/app_spacing.dart';
+import '../../../../core/widgets/shortcut_card.dart';
+import '../../../remote_config/domain/entities/app_config.dart';
+import '../../home_strings.dart';
+import 'quick_access_items.dart';
+
+/// Number of quick-access shortcuts shown on Home; the rest are only
+/// reachable from the "Ver todos" screen.
+const _homeQuickAccessCount = 6;
+
+class HomeQuickAccess extends StatelessWidget {
+ const HomeQuickAccess({
+ super.key,
+ required this.config,
+ required this.onViewAll,
+ });
+
+ final AppConfig? config;
+ final VoidCallback onViewAll;
+
+ @override
+ Widget build(BuildContext context) {
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Text(
+ HomeStrings.quickAccess,
+ style: Theme.of(context).textTheme.titleMedium,
+ ),
+ TextButton(
+ onPressed: onViewAll,
+ child: Row(
+ children: [
+ Text(HomeStrings.viewAll),
+ const Icon(Icons.chevron_right, size: 18),
+ ],
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: AppSpacing.sm),
+ GridView.count(
+ crossAxisCount: 3,
+ shrinkWrap: true,
+ physics: const NeverScrollableScrollPhysics(),
+ mainAxisSpacing: AppSpacing.md,
+ crossAxisSpacing: AppSpacing.md,
+ childAspectRatio: 0.85,
+ children: quickAccessItems(context, config)
+ .take(_homeQuickAccessCount)
+ .map(
+ (item) => ShortcutCard(
+ iconAsset: item.iconAsset,
+ label: item.label,
+ accent: item.accent,
+ onTap: item.onTap,
+ ),
+ )
+ .toList(),
+ ),
+ ],
+ );
+ }
+}
diff --git a/lib/features/home/presentation/widgets/quick_access_items.dart b/lib/features/home/presentation/widgets/quick_access_items.dart
index d132dca..a4b46bf 100644
--- a/lib/features/home/presentation/widgets/quick_access_items.dart
+++ b/lib/features/home/presentation/widgets/quick_access_items.dart
@@ -2,75 +2,144 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/constants/asset_paths.dart';
+import '../../../../core/extensions/snackbar_extension.dart';
import '../../../../core/router/app_routes.dart';
import '../../../../core/theme/app_colors.dart';
+import '../../../remote_config/domain/entities/app_config.dart';
+import '../../../remote_config/presentation/utils/module_style.dart';
import '../../home_strings.dart';
class QuickAccessItem {
const QuickAccessItem({
+ required this.key,
required this.iconAsset,
required this.label,
required this.accent,
required this.onTap,
});
+ final String key;
final String iconAsset;
final String label;
final Color accent;
final VoidCallback onTap;
}
-List quickAccessItems(BuildContext context) {
+/// Routes the app actually has screens for; a server-sent module pointing
+/// anywhere else falls back to `showComingSoon` instead of a dead navigation.
+const _knownRoutes = {
+ AppRoutes.grades,
+ AppRoutes.digitalCard,
+ AppRoutes.tabulate,
+ AppRoutes.resolution,
+ AppRoutes.teacherRating,
+ AppRoutes.schedule,
+ AppRoutes.library,
+ AppRoutes.restaurant,
+ AppRoutes.news,
+};
+
+/// The server (`config.modules` filtered/ordered by `config.quickAccess`) is
+/// the source of truth whenever it's available — unknown local shortcuts
+/// disappear if the backend doesn't mention them, and known ones pick up
+/// the label/icon/color the backend sends. Falls back to the local catalog
+/// only when [config] is null (still loading, or the backend is down and
+/// there's no cached config either).
+List quickAccessItems(
+ BuildContext context,
+ AppConfig? config,
+) {
+ if (config == null) return _localQuickAccessItems(context);
+
+ final byKey = {for (final module in config.modules) module.key: module};
+ final quickKeys = config.quickAccess.toSet();
+ final orderedModules = [
+ for (final key in config.quickAccess) ?byKey[key],
+ for (final module in config.modules)
+ if (!quickKeys.contains(module.key)) module,
+ ];
+
+ return orderedModules
+ .map(
+ (module) => QuickAccessItem(
+ key: module.key,
+ iconAsset: moduleIconAsset(module.icon),
+ label: module.label,
+ accent: moduleColor(module.color),
+ onTap: () => _goTo(context, module.route),
+ ),
+ )
+ .toList();
+}
+
+void _goTo(BuildContext context, String route) {
+ if (_knownRoutes.contains(route)) {
+ context.push(route);
+ } else {
+ context.showSnack(HomeStrings.comingSoon);
+ }
+}
+
+List _localQuickAccessItems(BuildContext context) {
return [
QuickAccessItem(
+ key: 'grades',
iconAsset: AssetPaths.iconNotebook,
label: HomeStrings.gradesShortcut,
accent: AppColors.univalleRed,
onTap: () => context.push(AppRoutes.grades),
),
QuickAccessItem(
+ key: 'digital_card',
iconAsset: AssetPaths.iconIdCard,
label: HomeStrings.studentCardShortcut,
accent: AppColors.accentPurple,
onTap: () => context.push(AppRoutes.digitalCard),
),
QuickAccessItem(
+ key: 'tabulate',
iconAsset: AssetPaths.iconLayers,
label: HomeStrings.tabuladoShortcut,
accent: AppColors.accentGreen,
onTap: () => context.push(AppRoutes.tabulate),
),
QuickAccessItem(
+ key: 'resolution',
iconAsset: AssetPaths.iconRoute,
label: HomeStrings.resolutionShortcut,
accent: AppColors.accentAmber,
onTap: () => context.push(AppRoutes.resolution),
),
QuickAccessItem(
+ key: 'teacher_rating',
iconAsset: AssetPaths.iconStar,
label: HomeStrings.teacherRatingShortcut,
accent: AppColors.accentBlue,
onTap: () => context.push(AppRoutes.teacherRating),
),
QuickAccessItem(
+ key: 'schedule',
iconAsset: AssetPaths.iconCalendar,
label: HomeStrings.scheduleShortcut,
accent: AppColors.accentPurple,
onTap: () => context.push(AppRoutes.schedule),
),
QuickAccessItem(
+ key: 'library',
iconAsset: AssetPaths.iconLibrary,
label: HomeStrings.libraryShortcut,
accent: AppColors.accentPink,
onTap: () => context.push(AppRoutes.library),
),
QuickAccessItem(
+ key: 'restaurant',
iconAsset: AssetPaths.iconUtensils,
label: HomeStrings.restaurantShortcut,
accent: AppColors.univalleRed,
onTap: () => context.push(AppRoutes.restaurant),
),
QuickAccessItem(
+ key: 'news',
iconAsset: AssetPaths.iconNewspaper,
label: HomeStrings.newsShortcut,
accent: AppColors.accentBlue,
diff --git a/lib/features/remote_config/data/datasources/remote_config_local_datasource.dart b/lib/features/remote_config/data/datasources/remote_config_local_datasource.dart
new file mode 100644
index 0000000..fce4e42
--- /dev/null
+++ b/lib/features/remote_config/data/datasources/remote_config_local_datasource.dart
@@ -0,0 +1,40 @@
+import 'dart:convert';
+
+import '../../../../core/storage/local_storage_service.dart';
+import '../models/app_config_model.dart';
+
+class RemoteConfigLocalDataSource {
+ static const _snapshotKey = 'remote_config_snapshot';
+ static const _dismissedUpdateKey = 'remote_config_dismissed_update';
+ static const _seenWelcomeKey = 'remote_config_seen_welcome';
+
+ final LocalStorageService _storage;
+ const RemoteConfigLocalDataSource(this._storage);
+
+ Future saveConfigSnapshot(Map json) =>
+ _storage.setString(_snapshotKey, jsonEncode(json));
+
+ /// Returns null both when there is no cached snapshot and when the cached
+ /// one fails to decode (treated the same as "no cache" rather than as an
+ /// error the fail-open caller would need to handle).
+ Future getConfigSnapshot() async {
+ final raw = await _storage.getString(_snapshotKey);
+ if (raw == null) return null;
+ try {
+ return AppConfigModel.fromJson(jsonDecode(raw) as Map);
+ } on FormatException {
+ return null;
+ }
+ }
+
+ Future dismissUpdate(String latestVersion) =>
+ _storage.setString(_dismissedUpdateKey, latestVersion);
+
+ Future getDismissedUpdate() =>
+ _storage.getString(_dismissedUpdateKey);
+
+ Future markWelcomeSeen(String fingerprint) =>
+ _storage.setString(_seenWelcomeKey, fingerprint);
+
+ Future getSeenWelcome() => _storage.getString(_seenWelcomeKey);
+}
diff --git a/lib/features/remote_config/data/datasources/remote_config_remote_datasource.dart b/lib/features/remote_config/data/datasources/remote_config_remote_datasource.dart
new file mode 100644
index 0000000..6d22070
--- /dev/null
+++ b/lib/features/remote_config/data/datasources/remote_config_remote_datasource.dart
@@ -0,0 +1,58 @@
+import 'package:dio/dio.dart';
+
+import '../../../../core/constants/api_constants.dart';
+import '../../../../core/network/dio_exception_mapper.dart';
+import '../models/app_config_model.dart';
+import '../models/announcement_model.dart';
+
+class RemoteConfigRemoteDataSource {
+ final Dio _dio;
+ const RemoteConfigRemoteDataSource(this._dio);
+
+ // The backend splits app-launch data across separate endpoints
+ // (`/app/config` only carries maintenance/update); fetch them together
+ // and merge into the single `AppConfigModel` the rest of the app expects.
+ Future fetchConfig({
+ required String platform,
+ required String version,
+ }) async {
+ final responses = await _run(
+ () => Future.wait([
+ _dio.get(
+ ApiConstants.configPath,
+ queryParameters: {'platform': platform, 'version': version},
+ ),
+ _dio.get(
+ ApiConstants.modulesPath,
+ queryParameters: {'platform': platform},
+ ),
+ _dio.get(ApiConstants.welcomePath),
+ ]),
+ );
+ return AppConfigModel.fromParts(
+ config: responses[0].data as Map,
+ modules: responses[1].data as Map,
+ welcome: responses[2].data as Map,
+ );
+ }
+
+ Future fetchAnnouncements({required int page}) async {
+ final response = await _run(
+ () => _dio.get(
+ ApiConstants.announcementsPath,
+ queryParameters: {'page': page},
+ ),
+ );
+ return AnnouncementsPageModel.fromJson(
+ response.data as Map,
+ );
+ }
+
+ Future _run(Future Function() request) async {
+ try {
+ return await request();
+ } on DioException catch (e) {
+ throw mapDioException(e);
+ }
+ }
+}
diff --git a/lib/features/remote_config/data/models/announcement_model.dart b/lib/features/remote_config/data/models/announcement_model.dart
new file mode 100644
index 0000000..29089b8
--- /dev/null
+++ b/lib/features/remote_config/data/models/announcement_model.dart
@@ -0,0 +1,73 @@
+import '../../domain/entities/announcement.dart';
+import '../../domain/entities/announcements_page.dart';
+
+class AnnouncementModel {
+ final int id;
+ final String title;
+ final String description;
+ final String? imageUrl;
+
+ const AnnouncementModel({
+ required this.id,
+ required this.title,
+ required this.description,
+ this.imageUrl,
+ });
+
+ factory AnnouncementModel.fromJson(Map json) {
+ return AnnouncementModel(
+ id: json['id'] as int,
+ title: json['title'] as String,
+ description: json['description'] as String,
+ imageUrl: json['imageUrl'] as String?,
+ );
+ }
+
+ Map toJson() => {
+ 'id': id,
+ 'title': title,
+ 'description': description,
+ 'imageUrl': imageUrl,
+ };
+
+ Announcement toEntity() => Announcement(
+ id: id,
+ title: title,
+ description: description,
+ imageUrl: imageUrl,
+ );
+}
+
+class AnnouncementsPageModel {
+ final List items;
+ final int page;
+ final int limit;
+ final int total;
+
+ const AnnouncementsPageModel({
+ required this.items,
+ required this.page,
+ required this.limit,
+ required this.total,
+ });
+
+ factory AnnouncementsPageModel.fromJson(Map json) {
+ return AnnouncementsPageModel(
+ items: (json['items'] as List)
+ .map(
+ (item) => AnnouncementModel.fromJson(item as Map),
+ )
+ .toList(),
+ page: json['page'] as int,
+ limit: json['limit'] as int,
+ total: json['total'] as int,
+ );
+ }
+
+ AnnouncementsPage toEntity() => AnnouncementsPage(
+ items: items.map((item) => item.toEntity()).toList(),
+ page: page,
+ limit: limit,
+ total: total,
+ );
+}
diff --git a/lib/features/remote_config/data/models/app_config_model.dart b/lib/features/remote_config/data/models/app_config_model.dart
new file mode 100644
index 0000000..d14d913
--- /dev/null
+++ b/lib/features/remote_config/data/models/app_config_model.dart
@@ -0,0 +1,157 @@
+import '../../domain/entities/app_config.dart';
+import 'app_module_model.dart';
+import 'welcome_banner_model.dart';
+
+class AppConfigModel {
+ final bool platformEnabled;
+ final MaintenanceStatusModel maintenance;
+ final UpdateStatusModel update;
+ final List modules;
+ final List quickAccess;
+ final WelcomeBannerModel welcome;
+
+ const AppConfigModel({
+ required this.platformEnabled,
+ required this.maintenance,
+ required this.update,
+ required this.modules,
+ required this.quickAccess,
+ required this.welcome,
+ });
+
+ // Round-trips the merged snapshot this app persists locally (see
+ // RemoteConfigLocalDataSource) — the backend itself never returns this
+ // shape from a single endpoint, see `fromParts`.
+ factory AppConfigModel.fromJson(Map json) {
+ return AppConfigModel(
+ platformEnabled: json['platformEnabled'] as bool,
+ maintenance: MaintenanceStatusModel.fromJson(
+ json['maintenance'] as Map,
+ ),
+ update: UpdateStatusModel.fromJson(
+ json['update'] as Map,
+ ),
+ modules: (json['modules'] as List)
+ .map((m) => AppModuleModel.fromJson(m as Map))
+ .toList(),
+ quickAccess: (json['quickAccess'] as List).cast(),
+ welcome: WelcomeBannerModel.fromJson(
+ json['welcome'] as Map,
+ ),
+ );
+ }
+
+ /// Builds the merged config from the backend's actual (split) response
+ /// shape: `/app/config` only carries `platformEnabled`/`maintenance`/
+ /// `update`, while modules and the welcome banner each live behind their
+ /// own endpoint.
+ factory AppConfigModel.fromParts({
+ required Map config,
+ required Map modules,
+ required Map welcome,
+ }) {
+ return AppConfigModel(
+ platformEnabled: config['platformEnabled'] as bool,
+ maintenance: MaintenanceStatusModel.fromJson(
+ config['maintenance'] as Map,
+ ),
+ update: UpdateStatusModel.fromJson(
+ config['update'] as Map,
+ ),
+ modules: (modules['items'] as List)
+ .map((m) => AppModuleModel.fromJson(m as Map))
+ .toList(),
+ quickAccess: (modules['quickAccess'] as List).cast(),
+ welcome: WelcomeBannerModel.fromJson(welcome),
+ );
+ }
+
+ Map toJson() => {
+ 'platformEnabled': platformEnabled,
+ 'maintenance': maintenance.toJson(),
+ 'update': update.toJson(),
+ 'modules': modules.map((m) => m.toJson()).toList(),
+ 'quickAccess': quickAccess,
+ 'welcome': welcome.toJson(),
+ };
+
+ AppConfig toEntity() => AppConfig(
+ platformEnabled: platformEnabled,
+ maintenance: maintenance.toEntity(),
+ update: update.toEntity(),
+ modules: modules.map((m) => m.toEntity()).toList(),
+ quickAccess: quickAccess,
+ welcome: welcome.toEntity(),
+ );
+}
+
+class MaintenanceStatusModel {
+ final bool enabled;
+ final String title;
+ final String message;
+
+ const MaintenanceStatusModel({
+ required this.enabled,
+ required this.title,
+ required this.message,
+ });
+
+ factory MaintenanceStatusModel.fromJson(Map json) {
+ return MaintenanceStatusModel(
+ enabled: json['enabled'] as bool,
+ title: json['title'] as String,
+ message: json['message'] as String,
+ );
+ }
+
+ Map toJson() => {
+ 'enabled': enabled,
+ 'title': title,
+ 'message': message,
+ };
+
+ MaintenanceStatus toEntity() =>
+ MaintenanceStatus(enabled: enabled, title: title, message: message);
+}
+
+class UpdateStatusModel {
+ final String latestVersion;
+ final bool updateAvailable;
+ final bool updateRequired;
+ final String storeUrl;
+ final String message;
+
+ const UpdateStatusModel({
+ required this.latestVersion,
+ required this.updateAvailable,
+ required this.updateRequired,
+ required this.storeUrl,
+ required this.message,
+ });
+
+ factory UpdateStatusModel.fromJson(Map json) {
+ return UpdateStatusModel(
+ latestVersion: json['latestVersion'] as String,
+ updateAvailable: json['updateAvailable'] as bool,
+ updateRequired: json['updateRequired'] as bool,
+ storeUrl: json['storeUrl'] as String,
+ message: json['message'] as String,
+ );
+ }
+
+ Map toJson() => {
+ 'latestVersion': latestVersion,
+ 'updateAvailable': updateAvailable,
+ 'updateRequired': updateRequired,
+ 'storeUrl': storeUrl,
+ 'message': message,
+ };
+
+ UpdateStatus toEntity() => UpdateStatus(
+ latestVersion: latestVersion,
+ updateAvailable: updateAvailable,
+ updateRequired: updateRequired,
+ storeUrl: storeUrl,
+ message: message,
+ );
+}
diff --git a/lib/features/remote_config/data/models/app_module_model.dart b/lib/features/remote_config/data/models/app_module_model.dart
new file mode 100644
index 0000000..5c6d187
--- /dev/null
+++ b/lib/features/remote_config/data/models/app_module_model.dart
@@ -0,0 +1,38 @@
+import '../../domain/entities/app_module.dart';
+
+class AppModuleModel {
+ final String key;
+ final String label;
+ final String icon;
+ final String route;
+ final String color;
+
+ const AppModuleModel({
+ required this.key,
+ required this.label,
+ required this.icon,
+ required this.route,
+ required this.color,
+ });
+
+ factory AppModuleModel.fromJson(Map json) {
+ return AppModuleModel(
+ key: json['key'] as String,
+ label: json['label'] as String,
+ icon: json['icon'] as String,
+ route: json['route'] as String,
+ color: json['color'] as String,
+ );
+ }
+
+ Map toJson() => {
+ 'key': key,
+ 'label': label,
+ 'icon': icon,
+ 'route': route,
+ 'color': color,
+ };
+
+ AppModule toEntity() =>
+ AppModule(key: key, label: label, icon: icon, route: route, color: color);
+}
diff --git a/lib/features/remote_config/data/models/welcome_banner_model.dart b/lib/features/remote_config/data/models/welcome_banner_model.dart
new file mode 100644
index 0000000..c630402
--- /dev/null
+++ b/lib/features/remote_config/data/models/welcome_banner_model.dart
@@ -0,0 +1,43 @@
+import '../../domain/entities/welcome_banner.dart';
+
+class WelcomeBannerModel {
+ final bool enabled;
+ final String title;
+ final String description;
+ final String? imageUrl;
+ final String? linkUrl;
+
+ const WelcomeBannerModel({
+ required this.enabled,
+ required this.title,
+ required this.description,
+ this.imageUrl,
+ this.linkUrl,
+ });
+
+ factory WelcomeBannerModel.fromJson(Map json) {
+ return WelcomeBannerModel(
+ enabled: json['enabled'] as bool,
+ title: json['title'] as String,
+ description: json['description'] as String,
+ imageUrl: json['imageUrl'] as String?,
+ linkUrl: json['linkUrl'] as String?,
+ );
+ }
+
+ Map toJson() => {
+ 'enabled': enabled,
+ 'title': title,
+ 'description': description,
+ 'imageUrl': imageUrl,
+ 'linkUrl': linkUrl,
+ };
+
+ WelcomeBanner toEntity() => WelcomeBanner(
+ enabled: enabled,
+ title: title,
+ description: description,
+ imageUrl: imageUrl,
+ linkUrl: linkUrl,
+ );
+}
diff --git a/lib/features/remote_config/data/repositories/remote_config_repository_impl.dart b/lib/features/remote_config/data/repositories/remote_config_repository_impl.dart
new file mode 100644
index 0000000..296f574
--- /dev/null
+++ b/lib/features/remote_config/data/repositories/remote_config_repository_impl.dart
@@ -0,0 +1,69 @@
+import '../../../../core/error/exception_mapper.dart';
+import '../../../../core/error/exceptions.dart';
+import '../../../../core/error/result.dart';
+import '../../domain/entities/app_config.dart';
+import '../../domain/entities/announcements_page.dart';
+import '../../domain/repositories/remote_config_repository.dart';
+import '../datasources/remote_config_local_datasource.dart';
+import '../datasources/remote_config_remote_datasource.dart';
+
+class RemoteConfigRepositoryImpl implements RemoteConfigRepository {
+ final RemoteConfigRemoteDataSource _remote;
+ final RemoteConfigLocalDataSource _local;
+ const RemoteConfigRepositoryImpl(this._remote, this._local);
+
+ @override
+ Future> getConfig({
+ required String platform,
+ required String version,
+ }) async {
+ try {
+ final model = await _remote.fetchConfig(
+ platform: platform,
+ version: version,
+ );
+ await _local.saveConfigSnapshot(model.toJson());
+ return Ok(model.toEntity());
+ } on AppException catch (e) {
+ final cached = await _local.getConfigSnapshot();
+ if (cached != null) return Ok(cached.toEntity());
+ return Err(mapExceptionToFailure(e));
+ }
+ }
+
+ @override
+ Future> getAnnouncements({
+ required int page,
+ }) async {
+ try {
+ final model = await _remote.fetchAnnouncements(page: page);
+ return Ok(model.toEntity());
+ } on AppException catch (e) {
+ return Err(mapExceptionToFailure(e));
+ }
+ }
+
+ @override
+ Future> getDismissedUpdate() =>
+ _guard(() => _local.getDismissedUpdate());
+
+ @override
+ Future> dismissUpdate(String latestVersion) =>
+ _guard(() => _local.dismissUpdate(latestVersion));
+
+ @override
+ Future> getSeenWelcome() =>
+ _guard(() => _local.getSeenWelcome());
+
+ @override
+ Future> markWelcomeSeen(String fingerprint) =>
+ _guard(() => _local.markWelcomeSeen(fingerprint));
+
+ Future> _guard(Future Function() operation) async {
+ try {
+ return Ok(await operation());
+ } on AppException catch (e) {
+ return Err(mapExceptionToFailure(e));
+ }
+ }
+}
diff --git a/lib/features/remote_config/domain/entities/announcement.dart b/lib/features/remote_config/domain/entities/announcement.dart
new file mode 100644
index 0000000..5e1126c
--- /dev/null
+++ b/lib/features/remote_config/domain/entities/announcement.dart
@@ -0,0 +1,13 @@
+class Announcement {
+ final int id;
+ final String title;
+ final String description;
+ final String? imageUrl;
+
+ const Announcement({
+ required this.id,
+ required this.title,
+ required this.description,
+ this.imageUrl,
+ });
+}
diff --git a/lib/features/remote_config/domain/entities/announcements_page.dart b/lib/features/remote_config/domain/entities/announcements_page.dart
new file mode 100644
index 0000000..2cb91c6
--- /dev/null
+++ b/lib/features/remote_config/domain/entities/announcements_page.dart
@@ -0,0 +1,17 @@
+import 'announcement.dart';
+
+class AnnouncementsPage {
+ final List items;
+ final int page;
+ final int limit;
+ final int total;
+
+ const AnnouncementsPage({
+ required this.items,
+ required this.page,
+ required this.limit,
+ required this.total,
+ });
+
+ bool get hasMore => page * limit < total;
+}
diff --git a/lib/features/remote_config/domain/entities/app_config.dart b/lib/features/remote_config/domain/entities/app_config.dart
new file mode 100644
index 0000000..2c15cbe
--- /dev/null
+++ b/lib/features/remote_config/domain/entities/app_config.dart
@@ -0,0 +1,48 @@
+import 'app_module.dart';
+import 'welcome_banner.dart';
+
+class AppConfig {
+ final bool platformEnabled;
+ final MaintenanceStatus maintenance;
+ final UpdateStatus update;
+ final List modules;
+ final List quickAccess;
+ final WelcomeBanner welcome;
+
+ const AppConfig({
+ required this.platformEnabled,
+ required this.maintenance,
+ required this.update,
+ required this.modules,
+ required this.quickAccess,
+ required this.welcome,
+ });
+}
+
+class MaintenanceStatus {
+ final bool enabled;
+ final String title;
+ final String message;
+
+ const MaintenanceStatus({
+ required this.enabled,
+ required this.title,
+ required this.message,
+ });
+}
+
+class UpdateStatus {
+ final String latestVersion;
+ final bool updateAvailable;
+ final bool updateRequired;
+ final String storeUrl;
+ final String message;
+
+ const UpdateStatus({
+ required this.latestVersion,
+ required this.updateAvailable,
+ required this.updateRequired,
+ required this.storeUrl,
+ required this.message,
+ });
+}
diff --git a/lib/features/remote_config/domain/entities/app_module.dart b/lib/features/remote_config/domain/entities/app_module.dart
new file mode 100644
index 0000000..f14dabe
--- /dev/null
+++ b/lib/features/remote_config/domain/entities/app_module.dart
@@ -0,0 +1,15 @@
+class AppModule {
+ final String key;
+ final String label;
+ final String icon;
+ final String route;
+ final String color;
+
+ const AppModule({
+ required this.key,
+ required this.label,
+ required this.icon,
+ required this.route,
+ required this.color,
+ });
+}
diff --git a/lib/features/remote_config/domain/entities/welcome_banner.dart b/lib/features/remote_config/domain/entities/welcome_banner.dart
new file mode 100644
index 0000000..1f437f9
--- /dev/null
+++ b/lib/features/remote_config/domain/entities/welcome_banner.dart
@@ -0,0 +1,15 @@
+class WelcomeBanner {
+ final bool enabled;
+ final String title;
+ final String description;
+ final String? imageUrl;
+ final String? linkUrl;
+
+ const WelcomeBanner({
+ required this.enabled,
+ required this.title,
+ required this.description,
+ this.imageUrl,
+ this.linkUrl,
+ });
+}
diff --git a/lib/features/remote_config/domain/repositories/remote_config_repository.dart b/lib/features/remote_config/domain/repositories/remote_config_repository.dart
new file mode 100644
index 0000000..f003990
--- /dev/null
+++ b/lib/features/remote_config/domain/repositories/remote_config_repository.dart
@@ -0,0 +1,18 @@
+import '../../../../core/error/result.dart';
+import '../entities/app_config.dart';
+import '../entities/announcements_page.dart';
+
+abstract interface class RemoteConfigRepository {
+ Future> getConfig({
+ required String platform,
+ required String version,
+ });
+
+ Future> getAnnouncements({required int page});
+
+ Future> getDismissedUpdate();
+ Future> dismissUpdate(String latestVersion);
+
+ Future> getSeenWelcome();
+ Future> markWelcomeSeen(String fingerprint);
+}
diff --git a/lib/features/remote_config/domain/usecases/dismiss_update_usecase.dart b/lib/features/remote_config/domain/usecases/dismiss_update_usecase.dart
new file mode 100644
index 0000000..f4a5da2
--- /dev/null
+++ b/lib/features/remote_config/domain/usecases/dismiss_update_usecase.dart
@@ -0,0 +1,10 @@
+import '../../../../core/error/result.dart';
+import '../repositories/remote_config_repository.dart';
+
+class DismissUpdateUseCase {
+ final RemoteConfigRepository _repository;
+ const DismissUpdateUseCase(this._repository);
+
+ Future> call(String latestVersion) =>
+ _repository.dismissUpdate(latestVersion);
+}
diff --git a/lib/features/remote_config/domain/usecases/get_announcements_usecase.dart b/lib/features/remote_config/domain/usecases/get_announcements_usecase.dart
new file mode 100644
index 0000000..5dd8837
--- /dev/null
+++ b/lib/features/remote_config/domain/usecases/get_announcements_usecase.dart
@@ -0,0 +1,11 @@
+import '../../../../core/error/result.dart';
+import '../entities/announcements_page.dart';
+import '../repositories/remote_config_repository.dart';
+
+class GetAnnouncementsUseCase {
+ final RemoteConfigRepository _repository;
+ const GetAnnouncementsUseCase(this._repository);
+
+ Future> call({required int page}) =>
+ _repository.getAnnouncements(page: page);
+}
diff --git a/lib/features/remote_config/domain/usecases/get_app_config_usecase.dart b/lib/features/remote_config/domain/usecases/get_app_config_usecase.dart
new file mode 100644
index 0000000..2d6d370
--- /dev/null
+++ b/lib/features/remote_config/domain/usecases/get_app_config_usecase.dart
@@ -0,0 +1,13 @@
+import '../../../../core/error/result.dart';
+import '../entities/app_config.dart';
+import '../repositories/remote_config_repository.dart';
+
+class GetAppConfigUseCase {
+ final RemoteConfigRepository _repository;
+ const GetAppConfigUseCase(this._repository);
+
+ Future> call({
+ required String platform,
+ required String version,
+ }) => _repository.getConfig(platform: platform, version: version);
+}
diff --git a/lib/features/remote_config/domain/usecases/get_dismissed_update_usecase.dart b/lib/features/remote_config/domain/usecases/get_dismissed_update_usecase.dart
new file mode 100644
index 0000000..14f6c6d
--- /dev/null
+++ b/lib/features/remote_config/domain/usecases/get_dismissed_update_usecase.dart
@@ -0,0 +1,9 @@
+import '../../../../core/error/result.dart';
+import '../repositories/remote_config_repository.dart';
+
+class GetDismissedUpdateUseCase {
+ final RemoteConfigRepository _repository;
+ const GetDismissedUpdateUseCase(this._repository);
+
+ Future> call() => _repository.getDismissedUpdate();
+}
diff --git a/lib/features/remote_config/domain/usecases/get_seen_welcome_usecase.dart b/lib/features/remote_config/domain/usecases/get_seen_welcome_usecase.dart
new file mode 100644
index 0000000..c791308
--- /dev/null
+++ b/lib/features/remote_config/domain/usecases/get_seen_welcome_usecase.dart
@@ -0,0 +1,9 @@
+import '../../../../core/error/result.dart';
+import '../repositories/remote_config_repository.dart';
+
+class GetSeenWelcomeUseCase {
+ final RemoteConfigRepository _repository;
+ const GetSeenWelcomeUseCase(this._repository);
+
+ Future> call() => _repository.getSeenWelcome();
+}
diff --git a/lib/features/remote_config/domain/usecases/mark_welcome_seen_usecase.dart b/lib/features/remote_config/domain/usecases/mark_welcome_seen_usecase.dart
new file mode 100644
index 0000000..44c7c2f
--- /dev/null
+++ b/lib/features/remote_config/domain/usecases/mark_welcome_seen_usecase.dart
@@ -0,0 +1,10 @@
+import '../../../../core/error/result.dart';
+import '../repositories/remote_config_repository.dart';
+
+class MarkWelcomeSeenUseCase {
+ final RemoteConfigRepository _repository;
+ const MarkWelcomeSeenUseCase(this._repository);
+
+ Future> call(String fingerprint) =>
+ _repository.markWelcomeSeen(fingerprint);
+}
diff --git a/lib/features/remote_config/presentation/providers/remote_config_providers.dart b/lib/features/remote_config/presentation/providers/remote_config_providers.dart
new file mode 100644
index 0000000..d1373fb
--- /dev/null
+++ b/lib/features/remote_config/presentation/providers/remote_config_providers.dart
@@ -0,0 +1,64 @@
+import 'package:riverpod_annotation/riverpod_annotation.dart';
+
+import '../../../../core/network/api_dio_provider.dart';
+import '../../../../core/storage/local_storage_providers.dart';
+import '../../data/datasources/remote_config_local_datasource.dart';
+import '../../data/datasources/remote_config_remote_datasource.dart';
+import '../../data/repositories/remote_config_repository_impl.dart';
+import '../../domain/repositories/remote_config_repository.dart';
+import '../../domain/usecases/dismiss_update_usecase.dart';
+import '../../domain/usecases/get_announcements_usecase.dart';
+import '../../domain/usecases/get_app_config_usecase.dart';
+import '../../domain/usecases/get_dismissed_update_usecase.dart';
+import '../../domain/usecases/get_seen_welcome_usecase.dart';
+import '../../domain/usecases/mark_welcome_seen_usecase.dart';
+
+part 'remote_config_providers.g.dart';
+
+@Riverpod(keepAlive: true)
+RemoteConfigRemoteDataSource remoteConfigRemoteDataSource(Ref ref) {
+ return RemoteConfigRemoteDataSource(ref.watch(apiDioProvider));
+}
+
+@Riverpod(keepAlive: true)
+RemoteConfigLocalDataSource remoteConfigLocalDataSource(Ref ref) {
+ return RemoteConfigLocalDataSource(ref.watch(localStorageServiceProvider));
+}
+
+@Riverpod(keepAlive: true)
+RemoteConfigRepository remoteConfigRepository(Ref ref) {
+ return RemoteConfigRepositoryImpl(
+ ref.watch(remoteConfigRemoteDataSourceProvider),
+ ref.watch(remoteConfigLocalDataSourceProvider),
+ );
+}
+
+@Riverpod(keepAlive: true)
+GetAppConfigUseCase getAppConfigUseCase(Ref ref) {
+ return GetAppConfigUseCase(ref.watch(remoteConfigRepositoryProvider));
+}
+
+@Riverpod(keepAlive: true)
+GetAnnouncementsUseCase getAnnouncementsUseCase(Ref ref) {
+ return GetAnnouncementsUseCase(ref.watch(remoteConfigRepositoryProvider));
+}
+
+@Riverpod(keepAlive: true)
+GetDismissedUpdateUseCase getDismissedUpdateUseCase(Ref ref) {
+ return GetDismissedUpdateUseCase(ref.watch(remoteConfigRepositoryProvider));
+}
+
+@Riverpod(keepAlive: true)
+DismissUpdateUseCase dismissUpdateUseCase(Ref ref) {
+ return DismissUpdateUseCase(ref.watch(remoteConfigRepositoryProvider));
+}
+
+@Riverpod(keepAlive: true)
+GetSeenWelcomeUseCase getSeenWelcomeUseCase(Ref ref) {
+ return GetSeenWelcomeUseCase(ref.watch(remoteConfigRepositoryProvider));
+}
+
+@Riverpod(keepAlive: true)
+MarkWelcomeSeenUseCase markWelcomeSeenUseCase(Ref ref) {
+ return MarkWelcomeSeenUseCase(ref.watch(remoteConfigRepositoryProvider));
+}
diff --git a/lib/features/remote_config/presentation/utils/module_style.dart b/lib/features/remote_config/presentation/utils/module_style.dart
new file mode 100644
index 0000000..1aeac81
--- /dev/null
+++ b/lib/features/remote_config/presentation/utils/module_style.dart
@@ -0,0 +1,33 @@
+import 'package:flutter/material.dart';
+
+import '../../../../core/constants/asset_paths.dart';
+import '../../../../core/theme/app_colors.dart';
+
+/// Maps a backend `AppModule.icon` name (as seeded in univalle-api, e.g.
+/// "notebook-text") to the matching local SVG asset. Falls back to a
+/// generic icon for names the app doesn't ship yet, so an unrecognized
+/// server-sent module degrades gracefully instead of crashing.
+String moduleIconAsset(String icon) {
+ const icons = {
+ 'notebook-text': AssetPaths.iconNotebook,
+ 'id-card': AssetPaths.iconIdCard,
+ 'layers': AssetPaths.iconLayers,
+ 'route': AssetPaths.iconRoute,
+ 'star': AssetPaths.iconStar,
+ 'calendar': AssetPaths.iconCalendar,
+ 'library': AssetPaths.iconLibrary,
+ 'utensils': AssetPaths.iconUtensils,
+ 'newspaper': AssetPaths.iconNewspaper,
+ };
+ return icons[icon] ?? AssetPaths.iconLink;
+}
+
+/// Parses a backend `AppModule.color` hex string (`#RRGGBB`). Falls back to
+/// [AppColors.univalleRed] for a missing or malformed value — this is a
+/// runtime parse of server data, not a hardcoded literal, so it doesn't
+/// violate the "colors only from AppColors" convention.
+Color moduleColor(String hex) {
+ final match = RegExp(r'^#([0-9A-Fa-f]{6})$').firstMatch(hex);
+ if (match == null) return AppColors.univalleRed;
+ return Color(int.parse('FF${match.group(1)}', radix: 16));
+}
diff --git a/lib/features/remote_config/presentation/viewmodels/announcements_view_model.dart b/lib/features/remote_config/presentation/viewmodels/announcements_view_model.dart
new file mode 100644
index 0000000..6ff892f
--- /dev/null
+++ b/lib/features/remote_config/presentation/viewmodels/announcements_view_model.dart
@@ -0,0 +1,58 @@
+import 'package:riverpod_annotation/riverpod_annotation.dart';
+
+import '../../domain/entities/announcement.dart';
+import '../providers/remote_config_providers.dart';
+
+part 'announcements_view_model.g.dart';
+
+class AnnouncementsFeed {
+ final List items;
+ final bool hasMore;
+ const AnnouncementsFeed({required this.items, required this.hasMore});
+}
+
+@riverpod
+class AnnouncementsViewModel extends _$AnnouncementsViewModel {
+ int _page = 1;
+ bool _isLoadingMore = false;
+
+ @override
+ Future build() async {
+ _page = 1;
+ final result = await ref
+ .read(getAnnouncementsUseCaseProvider)
+ .call(page: _page);
+ return result.fold(
+ onError: (failure) => throw failure,
+ onSuccess: (page) =>
+ AnnouncementsFeed(items: page.items, hasMore: page.hasMore),
+ );
+ }
+
+ Future loadMore() async {
+ final feed = state.value;
+ if (feed == null || !feed.hasMore || _isLoadingMore) return;
+
+ _isLoadingMore = true;
+ try {
+ final nextPage = _page + 1;
+ final result = await ref
+ .read(getAnnouncementsUseCaseProvider)
+ .call(page: nextPage);
+ result.fold(
+ onError: (failure) => throw failure,
+ onSuccess: (page) {
+ _page = nextPage;
+ state = AsyncData(
+ AnnouncementsFeed(
+ items: [...feed.items, ...page.items],
+ hasMore: page.hasMore,
+ ),
+ );
+ },
+ );
+ } finally {
+ _isLoadingMore = false;
+ }
+ }
+}
diff --git a/lib/features/remote_config/presentation/viewmodels/home_overlays_view_model.dart b/lib/features/remote_config/presentation/viewmodels/home_overlays_view_model.dart
new file mode 100644
index 0000000..9e1f049
--- /dev/null
+++ b/lib/features/remote_config/presentation/viewmodels/home_overlays_view_model.dart
@@ -0,0 +1,57 @@
+import 'dart:convert';
+
+import 'package:crypto/crypto.dart';
+import 'package:riverpod_annotation/riverpod_annotation.dart';
+
+import '../../domain/entities/app_config.dart';
+import '../../domain/entities/welcome_banner.dart';
+import '../providers/remote_config_providers.dart';
+
+part 'home_overlays_view_model.g.dart';
+
+/// Decides whether [HomeOverlays] should show the update sheet / welcome
+/// dialog and persists dismissal, so the widget only orchestrates UI
+/// (opening the sheet/dialog) instead of talking to storage directly.
+@Riverpod(keepAlive: true)
+class HomeOverlaysViewModel extends _$HomeOverlaysViewModel {
+ @override
+ void build() {}
+
+ // A storage read failing is treated as "already dismissed" rather than
+ // "never dismissed", so a broken cache can't make an overlay reappear on
+ // every Home visit.
+ Future shouldShowUpdateSheet(UpdateStatus update) async {
+ if (!update.updateAvailable || update.updateRequired) return false;
+ final result = await ref.read(getDismissedUpdateUseCaseProvider).call();
+ final dismissed = result.fold(
+ onError: (_) => update.latestVersion,
+ onSuccess: (value) => value,
+ );
+ return dismissed != update.latestVersion;
+ }
+
+ Future dismissUpdate(String latestVersion) =>
+ ref.read(dismissUpdateUseCaseProvider).call(latestVersion);
+
+ Future shouldShowWelcomeBanner(WelcomeBanner banner) async {
+ if (!banner.enabled) return false;
+ final fingerprint = _fingerprintOf(banner);
+ final result = await ref.read(getSeenWelcomeUseCaseProvider).call();
+ final seen = result.fold(
+ onError: (_) => fingerprint,
+ onSuccess: (value) => value,
+ );
+ return seen != fingerprint;
+ }
+
+ Future markWelcomeSeen(WelcomeBanner banner) =>
+ ref.read(markWelcomeSeenUseCaseProvider).call(_fingerprintOf(banner));
+
+ // The backend exposes no id/version for the banner, so its own content is
+ // the only signal that the admin published something new.
+ String _fingerprintOf(WelcomeBanner banner) {
+ final raw =
+ '${banner.title}|${banner.description}|${banner.imageUrl ?? ''}';
+ return md5.convert(utf8.encode(raw)).toString();
+ }
+}
diff --git a/lib/features/remote_config/presentation/viewmodels/remote_config_view_model.dart b/lib/features/remote_config/presentation/viewmodels/remote_config_view_model.dart
new file mode 100644
index 0000000..23acf46
--- /dev/null
+++ b/lib/features/remote_config/presentation/viewmodels/remote_config_view_model.dart
@@ -0,0 +1,26 @@
+import 'package:riverpod_annotation/riverpod_annotation.dart';
+
+import '../../../../core/device/app_platform_provider.dart';
+import '../../../../core/device/app_version_provider.dart';
+import '../../domain/entities/app_config.dart';
+import '../providers/remote_config_providers.dart';
+
+part 'remote_config_view_model.g.dart';
+
+@Riverpod(keepAlive: true)
+class RemoteConfigViewModel extends _$RemoteConfigViewModel {
+ @override
+ Future build() async {
+ final platform = ref.watch(appPlatformProvider);
+ final version = await ref.watch(appVersionProvider.future);
+ final result = await ref
+ .read(getAppConfigUseCaseProvider)
+ .call(platform: platform, version: version);
+
+ // Fail-open, unlike every other viewmodel in this app: the backend must
+ // never be able to lock people out, so a Failure resolves to `null`
+ // ("no server config, fall back to local defaults") instead of being
+ // rethrown for AsyncValueWidget/AppErrorView to render.
+ return result.fold(onError: (_) => null, onSuccess: (config) => config);
+ }
+}
diff --git a/lib/features/remote_config/presentation/views/announcements_view.dart b/lib/features/remote_config/presentation/views/announcements_view.dart
new file mode 100644
index 0000000..d7375de
--- /dev/null
+++ b/lib/features/remote_config/presentation/views/announcements_view.dart
@@ -0,0 +1,86 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import '../../../../core/error/failures.dart';
+import '../../../../core/extensions/snackbar_extension.dart';
+import '../../../../core/theme/app_spacing.dart';
+import '../../../../core/widgets/app_loading_indicator.dart';
+import '../../../../core/widgets/app_scaffold.dart';
+import '../../../../core/widgets/async_value_widget.dart';
+import '../../../home/home_strings.dart';
+import '../viewmodels/announcements_view_model.dart';
+import '../widgets/announcement_card.dart';
+
+class AnnouncementsView extends ConsumerStatefulWidget {
+ const AnnouncementsView({super.key});
+
+ @override
+ ConsumerState createState() => _AnnouncementsViewState();
+}
+
+class _AnnouncementsViewState extends ConsumerState {
+ final _scrollController = ScrollController();
+
+ @override
+ void initState() {
+ super.initState();
+ _scrollController.addListener(_onScroll);
+ }
+
+ @override
+ void dispose() {
+ _scrollController.removeListener(_onScroll);
+ _scrollController.dispose();
+ super.dispose();
+ }
+
+ void _onScroll() {
+ final position = _scrollController.position;
+ if (position.pixels < position.maxScrollExtent - 300) return;
+ _loadMore();
+ }
+
+ Future _loadMore() async {
+ try {
+ await ref.read(announcementsViewModelProvider.notifier).loadMore();
+ } on Failure catch (failure) {
+ if (mounted) context.showSnack(failure.userMessage);
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final announcementsState = ref.watch(announcementsViewModelProvider);
+
+ return AppScaffold(
+ title: HomeStrings.announcementsTitle,
+ scrollable: false,
+ body: AsyncValueWidget(
+ value: announcementsState,
+ onRetry: () => ref.invalidate(announcementsViewModelProvider),
+ data: (feed) => feed.items.isEmpty
+ ? Center(
+ child: Text(
+ HomeStrings.announcementsEmptyMessage,
+ style: Theme.of(context).textTheme.bodyMedium,
+ ),
+ )
+ : ListView.separated(
+ controller: _scrollController,
+ itemCount: feed.items.length + (feed.hasMore ? 1 : 0),
+ separatorBuilder: (context, index) =>
+ const SizedBox(height: AppSpacing.sm),
+ itemBuilder: (context, index) {
+ if (index >= feed.items.length) {
+ return const Padding(
+ padding: EdgeInsets.symmetric(vertical: AppSpacing.md),
+ child: Center(child: AppLoadingIndicator()),
+ );
+ }
+ return AnnouncementCard(announcement: feed.items[index]);
+ },
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/features/remote_config/presentation/views/maintenance_view.dart b/lib/features/remote_config/presentation/views/maintenance_view.dart
new file mode 100644
index 0000000..5c64630
--- /dev/null
+++ b/lib/features/remote_config/presentation/views/maintenance_view.dart
@@ -0,0 +1,24 @@
+import 'package:flutter/material.dart';
+
+import '../../domain/entities/app_config.dart';
+import '../../remote_config_strings.dart';
+import '../widgets/blocking_message_view.dart';
+
+class MaintenanceView extends StatelessWidget {
+ const MaintenanceView({super.key, required this.maintenance});
+
+ final MaintenanceStatus maintenance;
+
+ @override
+ Widget build(BuildContext context) {
+ return BlockingMessageView(
+ icon: Icons.build_circle_outlined,
+ title: maintenance.title.isNotEmpty
+ ? maintenance.title
+ : RemoteConfigStrings.maintenanceTitleFallback,
+ message: maintenance.message.isNotEmpty
+ ? maintenance.message
+ : RemoteConfigStrings.maintenanceMessageFallback,
+ );
+ }
+}
diff --git a/lib/features/remote_config/presentation/views/update_required_view.dart b/lib/features/remote_config/presentation/views/update_required_view.dart
new file mode 100644
index 0000000..291c154
--- /dev/null
+++ b/lib/features/remote_config/presentation/views/update_required_view.dart
@@ -0,0 +1,36 @@
+import 'package:flutter/material.dart';
+import 'package:url_launcher/url_launcher.dart';
+
+import '../../../../core/extensions/snackbar_extension.dart';
+import '../../domain/entities/app_config.dart';
+import '../../remote_config_strings.dart';
+import '../widgets/blocking_message_view.dart';
+
+class UpdateRequiredView extends StatelessWidget {
+ const UpdateRequiredView({super.key, required this.update});
+
+ final UpdateStatus update;
+
+ Future _openStore(BuildContext context) async {
+ final uri = Uri.tryParse(update.storeUrl);
+ final launched =
+ uri != null &&
+ await launchUrl(uri, mode: LaunchMode.externalApplication);
+ if (!launched && context.mounted) {
+ context.showSnack(RemoteConfigStrings.cannotOpenStoreLink);
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return BlockingMessageView(
+ icon: Icons.system_update_outlined,
+ title: RemoteConfigStrings.updateRequiredTitle,
+ message: update.message.isNotEmpty
+ ? update.message
+ : RemoteConfigStrings.updateRequiredMessageFallback,
+ actionLabel: RemoteConfigStrings.updateButton,
+ onAction: () => _openStore(context),
+ );
+ }
+}
diff --git a/lib/features/remote_config/presentation/widgets/announcement_card.dart b/lib/features/remote_config/presentation/widgets/announcement_card.dart
new file mode 100644
index 0000000..5ef090f
--- /dev/null
+++ b/lib/features/remote_config/presentation/widgets/announcement_card.dart
@@ -0,0 +1,83 @@
+import 'package:flutter/material.dart';
+
+import '../../../../core/theme/app_colors.dart';
+import '../../../../core/theme/app_spacing.dart';
+import '../../domain/entities/announcement.dart';
+
+class AnnouncementCard extends StatelessWidget {
+ const AnnouncementCard({super.key, required this.announcement});
+
+ final Announcement announcement;
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+ return Material(
+ color: AppColors.white,
+ shadowColor: AppColors.accentPurple,
+ borderRadius: BorderRadius.circular(20),
+ elevation: .1,
+ child: Padding(
+ padding: const EdgeInsets.all(14),
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ if (announcement.imageUrl != null) ...[
+ ClipRRect(
+ borderRadius: BorderRadius.circular(14),
+ child: Image.network(
+ announcement.imageUrl!,
+ width: 64,
+ height: 64,
+ fit: BoxFit.cover,
+ errorBuilder: (context, error, stackTrace) =>
+ const _ThumbnailPlaceholder(),
+ ),
+ ),
+ const SizedBox(width: AppSpacing.sm + 4),
+ ],
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ announcement.title,
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ style: Theme.of(context).textTheme.bodyMedium
+ ?.copyWith(fontWeight: FontWeight.w600),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ announcement.description,
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ style: Theme.of(context).textTheme.bodySmall
+ ?.copyWith(color: colorScheme.onSurfaceVariant),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+class _ThumbnailPlaceholder extends StatelessWidget {
+ const _ThumbnailPlaceholder();
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ width: 64,
+ height: 64,
+ decoration: BoxDecoration(
+ color: AppColors.accentPurple.withValues(alpha: 0.12),
+ borderRadius: BorderRadius.circular(14),
+ ),
+ child: const Icon(Icons.campaign_outlined, color: AppColors.accentPurple),
+ );
+ }
+}
diff --git a/lib/features/remote_config/presentation/widgets/blocking_message_view.dart b/lib/features/remote_config/presentation/widgets/blocking_message_view.dart
new file mode 100644
index 0000000..a3fbc6f
--- /dev/null
+++ b/lib/features/remote_config/presentation/widgets/blocking_message_view.dart
@@ -0,0 +1,58 @@
+import 'package:flutter/material.dart';
+
+import '../../../../core/theme/app_spacing.dart';
+import '../../../../core/widgets/app_scaffold.dart';
+
+/// Shared layout for a full-screen, non-dismissible message (maintenance,
+/// forced update): logo, title, message, and an optional action — no
+/// `AppBar`, so there's no back affordance out of it.
+class BlockingMessageView extends StatelessWidget {
+ const BlockingMessageView({
+ super.key,
+ required this.icon,
+ required this.title,
+ required this.message,
+ this.actionLabel,
+ this.onAction,
+ });
+
+ final IconData icon;
+ final String title;
+ final String message;
+ final String? actionLabel;
+ final VoidCallback? onAction;
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+ return AppScaffold(
+ scrollable: false,
+ body: Column(
+ crossAxisAlignment: CrossAxisAlignment.center,
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ const SizedBox(height: AppSpacing.lg),
+ Icon(icon, size: 40, color: colorScheme.error),
+ const SizedBox(height: AppSpacing.md),
+ Text(
+ title,
+ textAlign: TextAlign.center,
+ style: Theme.of(context).textTheme.titleLarge
+ ?.copyWith(fontWeight: FontWeight.bold),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ message,
+ textAlign: TextAlign.center,
+ style: Theme.of(context).textTheme.bodyMedium
+ ?.copyWith(color: colorScheme.onSurfaceVariant),
+ ),
+ if (actionLabel != null && onAction != null) ...[
+ const SizedBox(height: AppSpacing.lg),
+ FilledButton(onPressed: onAction, child: Text(actionLabel!)),
+ ],
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/features/remote_config/presentation/widgets/home_overlays.dart b/lib/features/remote_config/presentation/widgets/home_overlays.dart
new file mode 100644
index 0000000..da4968a
--- /dev/null
+++ b/lib/features/remote_config/presentation/widgets/home_overlays.dart
@@ -0,0 +1,77 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import '../../domain/entities/app_config.dart';
+import '../viewmodels/home_overlays_view_model.dart';
+import 'update_available_sheet.dart';
+import 'welcome_banner_dialog.dart';
+
+/// Mounted inside Home; on first build with a resolved [AppConfig] it shows
+/// at most one overlay, in priority order: a bottom sheet for an optional
+/// app update, or (if none, or already dismissed) a dialog for the welcome
+/// banner. The two never stack.
+class HomeOverlays extends ConsumerStatefulWidget {
+ const HomeOverlays({super.key, required this.config});
+
+ final AppConfig? config;
+
+ @override
+ ConsumerState createState() => _HomeOverlaysState();
+}
+
+class _HomeOverlaysState extends ConsumerState {
+ bool _shown = false;
+
+ @override
+ void initState() {
+ super.initState();
+ _maybeScheduleShow();
+ }
+
+ // `config` typically arrives via a rebuild (the async config fetch
+ // resolving after Home first mounts), not at construction time, so this
+ // has to be checked again on every widget update, not just in initState.
+ @override
+ void didUpdateWidget(covariant HomeOverlays oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ _maybeScheduleShow();
+ }
+
+ void _maybeScheduleShow() {
+ final config = widget.config;
+ if (_shown || config == null) return;
+ _shown = true;
+ WidgetsBinding.instance.addPostFrameCallback((_) => _maybeShow(config));
+ }
+
+ Future _maybeShow(AppConfig config) async {
+ if (!mounted) return;
+ final overlays = ref.read(homeOverlaysViewModelProvider.notifier);
+
+ final update = config.update;
+ if (await overlays.shouldShowUpdateSheet(update)) {
+ if (!mounted) return;
+ await showModalBottomSheet(
+ context: context,
+ isScrollControlled: true,
+ backgroundColor: Colors.transparent,
+ builder: (_) => UpdateAvailableSheet(update: update),
+ );
+ await overlays.dismissUpdate(update.latestVersion);
+ return;
+ }
+
+ final welcome = config.welcome;
+ if (await overlays.shouldShowWelcomeBanner(welcome)) {
+ if (!mounted) return;
+ await showDialog(
+ context: context,
+ builder: (_) => WelcomeBannerDialog(banner: welcome),
+ );
+ await overlays.markWelcomeSeen(welcome);
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) => const SizedBox.shrink();
+}
diff --git a/lib/features/remote_config/presentation/widgets/remote_config_gate.dart b/lib/features/remote_config/presentation/widgets/remote_config_gate.dart
new file mode 100644
index 0000000..350d780
--- /dev/null
+++ b/lib/features/remote_config/presentation/widgets/remote_config_gate.dart
@@ -0,0 +1,39 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import '../../domain/entities/app_config.dart';
+import '../viewmodels/remote_config_view_model.dart';
+import '../views/maintenance_view.dart';
+import '../views/update_required_view.dart';
+
+/// Wraps the whole app (from `MaterialApp.router`'s `builder:` slot). While
+/// the config is loading, or if fetching it failed, the app underneath
+/// renders normally — see the fail-open comment on [RemoteConfigViewModel].
+/// Only an explicit maintenance flag or a required update blocks the app;
+/// everything else in [AppConfig] (modules, banner, announcements) is
+/// consumed further down, inside Home.
+class RemoteConfigGate extends ConsumerWidget {
+ const RemoteConfigGate({super.key, required this.child});
+
+ final Widget child;
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final config = ref.watch(remoteConfigViewModelProvider).value;
+ if (config == null) return child;
+
+ if (config.maintenance.enabled) {
+ return MaintenanceView(maintenance: config.maintenance);
+ }
+ if (!config.platformEnabled) {
+ return const MaintenanceView(
+ maintenance: MaintenanceStatus(enabled: true, title: '', message: ''),
+ );
+ }
+ if (config.update.updateRequired) {
+ return UpdateRequiredView(update: config.update);
+ }
+
+ return child;
+ }
+}
diff --git a/lib/features/remote_config/presentation/widgets/update_available_sheet.dart b/lib/features/remote_config/presentation/widgets/update_available_sheet.dart
new file mode 100644
index 0000000..8fde1ba
--- /dev/null
+++ b/lib/features/remote_config/presentation/widgets/update_available_sheet.dart
@@ -0,0 +1,100 @@
+import 'package:flutter/material.dart';
+import 'package:url_launcher/url_launcher.dart';
+
+import '../../../../core/extensions/snackbar_extension.dart';
+import '../../../../core/theme/app_colors.dart';
+import '../../../../core/theme/app_spacing.dart';
+import '../../domain/entities/app_config.dart';
+import '../../remote_config_strings.dart';
+
+class UpdateAvailableSheet extends StatelessWidget {
+ const UpdateAvailableSheet({super.key, required this.update});
+
+ final UpdateStatus update;
+
+ Future _openStore(BuildContext context) async {
+ final uri = Uri.tryParse(update.storeUrl);
+ final launched =
+ uri != null &&
+ await launchUrl(uri, mode: LaunchMode.externalApplication);
+ if (!launched && context.mounted) {
+ context.showSnack(RemoteConfigStrings.cannotOpenStoreLink);
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+ return SafeArea(
+ top: false,
+ child: Container(
+ decoration: const BoxDecoration(
+ color: AppColors.pageBackground,
+ borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
+ ),
+ padding: const EdgeInsets.fromLTRB(
+ AppSpacing.lg,
+ AppSpacing.sm,
+ AppSpacing.lg,
+ AppSpacing.lg,
+ ),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Center(
+ child: Container(
+ width: 40,
+ height: 4,
+ margin: const EdgeInsets.only(bottom: AppSpacing.md),
+ decoration: BoxDecoration(
+ color: colorScheme.outlineVariant,
+ borderRadius: BorderRadius.circular(2),
+ ),
+ ),
+ ),
+ Row(
+ children: [
+ const Icon(
+ Icons.system_update_outlined,
+ color: AppColors.accentBlue,
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Text(
+ RemoteConfigStrings.updateAvailableTitle,
+ style: Theme.of(context).textTheme.titleMedium
+ ?.copyWith(fontWeight: FontWeight.bold),
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 8),
+ Text(
+ update.message.isNotEmpty
+ ? update.message
+ : RemoteConfigStrings.updateAvailableMessageFallback,
+ style: Theme.of(context).textTheme.bodyMedium,
+ ),
+ const SizedBox(height: AppSpacing.lg),
+ SizedBox(
+ width: double.infinity,
+ child: FilledButton(
+ onPressed: () => _openStore(context),
+ child: const Text(RemoteConfigStrings.updateButton),
+ ),
+ ),
+ const SizedBox(height: 8),
+ SizedBox(
+ width: double.infinity,
+ child: TextButton(
+ onPressed: () => Navigator.of(context).pop(),
+ child: const Text(RemoteConfigStrings.updateAvailableLater),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/features/remote_config/presentation/widgets/welcome_banner_dialog.dart b/lib/features/remote_config/presentation/widgets/welcome_banner_dialog.dart
new file mode 100644
index 0000000..ad03a0b
--- /dev/null
+++ b/lib/features/remote_config/presentation/widgets/welcome_banner_dialog.dart
@@ -0,0 +1,87 @@
+import 'package:flutter/material.dart';
+import 'package:univalle_app/core/theme/app_colors.dart';
+import 'package:url_launcher/url_launcher.dart';
+
+import '../../../../core/extensions/snackbar_extension.dart';
+import '../../../../core/theme/app_spacing.dart';
+import '../../domain/entities/welcome_banner.dart' as domain;
+import '../../remote_config_strings.dart';
+
+/// The image is the point of this dialog, so it takes the full width up
+/// top; text and actions follow below. Shown with the image missing (rather
+/// than skipped) when `imageUrl` is null.
+class WelcomeBannerDialog extends StatelessWidget {
+ const WelcomeBannerDialog({super.key, required this.banner});
+
+ final domain.WelcomeBanner banner;
+
+ Future _openLink(BuildContext context) async {
+ final uri = Uri.tryParse(banner.linkUrl ?? '');
+ final launched =
+ uri != null &&
+ await launchUrl(uri, mode: LaunchMode.externalApplication);
+ if (!launched && context.mounted) {
+ context.showSnack(RemoteConfigStrings.cannotOpenStoreLink);
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Dialog(
+ backgroundColor: AppColors.white,
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
+ clipBehavior: Clip.antiAlias,
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ if (banner.imageUrl != null)
+ Image.network(
+ banner.imageUrl!,
+ width: double.infinity,
+ height: 180,
+ fit: BoxFit.cover,
+ errorBuilder: (context, error, stackTrace) =>
+ const SizedBox(height: 0),
+ ),
+ Padding(
+ padding: const EdgeInsets.all(AppSpacing.lg),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(
+ banner.title,
+ textAlign: TextAlign.center,
+ style: Theme.of(context).textTheme.titleMedium
+ ?.copyWith(fontWeight: FontWeight.bold),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ banner.description,
+ textAlign: TextAlign.center,
+ style: Theme.of(context).textTheme.bodyMedium,
+ ),
+ const SizedBox(height: AppSpacing.lg),
+ if (banner.linkUrl != null) ...[
+ SizedBox(
+ width: double.infinity,
+ child: FilledButton(
+ onPressed: () => _openLink(context),
+ child: const Text(
+ RemoteConfigStrings.welcomeDialogOpenLink,
+ ),
+ ),
+ ),
+ const SizedBox(height: 8),
+ ],
+ TextButton(
+ onPressed: () => Navigator.of(context).pop(),
+ child: const Text(RemoteConfigStrings.welcomeDialogClose),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/features/remote_config/remote_config_strings.dart b/lib/features/remote_config/remote_config_strings.dart
new file mode 100644
index 0000000..7c1610e
--- /dev/null
+++ b/lib/features/remote_config/remote_config_strings.dart
@@ -0,0 +1,19 @@
+abstract final class RemoteConfigStrings {
+ static const maintenanceTitleFallback = 'Estamos en mantenimiento';
+ static const maintenanceMessageFallback =
+ 'Volvemos pronto. Gracias por tu paciencia.';
+
+ static const updateRequiredTitle = 'Actualización requerida';
+ static const updateRequiredMessageFallback =
+ 'Necesitás actualizar la app para seguir usándola.';
+ static const updateButton = 'Actualizar';
+ static const cannotOpenStoreLink = 'No pudimos abrir la tienda de apps.';
+
+ static const updateAvailableTitle = 'Nueva versión disponible';
+ static const updateAvailableMessageFallback =
+ 'Hay una nueva versión de la app con mejoras.';
+ static const updateAvailableLater = 'Ahora no';
+
+ static const welcomeDialogClose = 'Cerrar';
+ static const welcomeDialogOpenLink = 'Ver más';
+}
diff --git a/lib/features/student_tabulate/data/datasources/sira_tabulate_remote_datasource.dart b/lib/features/student_tabulate/data/datasources/sira_tabulate_remote_datasource.dart
index 8e9d0f0..9b8fd15 100644
--- a/lib/features/student_tabulate/data/datasources/sira_tabulate_remote_datasource.dart
+++ b/lib/features/student_tabulate/data/datasources/sira_tabulate_remote_datasource.dart
@@ -37,7 +37,7 @@ class SiraTabulateRemoteDataSource {
_relocateHeadElements(document);
_removeDeadInteractiveElements(document);
_removeCruft(document);
- _scaleToFitViewport(document);
+ _absolutizeResourceUrls(document);
return document.outerHtml;
}
@@ -105,43 +105,19 @@ class SiraTabulateRemoteDataSource {
}
}
- // The document is a fixed-width print layout (the frame table is
- // 765px), and it's a formal university record: reflowing its tables would
- // change what it actually says fits where, which we don't want to touch.
- // Instead we wrap the whole body and scale it down as one image would be
- // scaled: `zoom` (unlike `transform: scale`) affects layout, so the page's
- // own scrollHeight comes out already shrunk to the device width.
- void _scaleToFitViewport(Document document) {
- final designWidth = _frameWidthOf(document);
-
- document.head!.append(
- Element.tag('meta')
- ..attributes['name'] = 'viewport'
- ..attributes['content'] = 'width=device-width, initial-scale=1',
- );
- document.head!.append(
- Element.tag('style')
- ..text =
- '''
- html, body { margin: 0 !important; overflow-x: hidden; }
- #mobileWrapper { zoom: calc(100vw / ${designWidth}px); }
- ''',
- );
-
- final body = document.body!;
- final wrapper = Element.tag('div')..attributes['id'] = 'mobileWrapper';
- for (final child in List.from(body.nodes)) {
- child.remove();
- wrapper.append(child);
+ // SIRA's markup points at its images and stylesheet with paths relative
+ // to the site root (e.g. "/sra/..."), which only resolve while the page
+ // loads inside sira.univalle.edu.co itself. The tag from
+ // [_relocateHeadElements] covers most WebViews, but rewriting these
+ // explicitly to absolute URLs doesn't depend on support.
+ void _absolutizeResourceUrls(Document document) {
+ final base = Uri.parse(SiraConstants.baseUrl);
+ for (final element in document.querySelectorAll('img, link')) {
+ final attribute = element.localName == 'link' ? 'href' : 'src';
+ final value = element.attributes[attribute];
+ if (value == null || value.isEmpty) continue;
+ element.attributes[attribute] = base.resolve(value).toString();
}
- body.append(wrapper);
- }
-
- int _frameWidthOf(Document document) {
- final width = int.tryParse(
- document.querySelector('#tableFramework')?.attributes['width'] ?? '',
- );
- return width ?? 765;
}
Future _run(Future Function() request) async {
diff --git a/lib/features/student_tabulate/presentation/views/tabulate_view.dart b/lib/features/student_tabulate/presentation/views/tabulate_view.dart
index f16f9c0..0fef40c 100644
--- a/lib/features/student_tabulate/presentation/views/tabulate_view.dart
+++ b/lib/features/student_tabulate/presentation/views/tabulate_view.dart
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:webview_flutter/webview_flutter.dart';
+import 'package:webview_flutter_android/webview_flutter_android.dart';
import '../../../../core/router/app_routes.dart';
import '../../../../core/widgets/app_scaffold.dart';
@@ -20,32 +21,15 @@ class TabulateView extends ConsumerStatefulWidget {
class _TabulateViewState extends ConsumerState {
final _controller = WebViewController()
- ..setJavaScriptMode(JavaScriptMode.unrestricted);
+ ..setJavaScriptMode(JavaScriptMode.unrestricted)
+ .._fitWideContentToScreen();
- double? _contentHeight;
String? _loadedHtml;
void _loadIfNeeded(Tabulate tabulate) {
if (_loadedHtml == tabulate.html) return;
_loadedHtml = tabulate.html;
- _contentHeight = null;
- _controller
- ..setNavigationDelegate(
- NavigationDelegate(onPageFinished: (_) => _measureContentHeight()),
- )
- ..loadHtmlString(tabulate.html, baseUrl: tabulate.baseUrl.toString());
- }
-
- // The document has no intrinsic height in Flutter's layout (it's a native
- // WebView), so we ask the page itself how tall it rendered and resize the
- // widget to match, letting the surrounding scroll view reach the full
- // document instead of clipping it to one screen.
- Future _measureContentHeight() async {
- final result = await _controller.runJavaScriptReturningResult(
- 'document.documentElement.scrollHeight',
- );
- final height = double.tryParse(result.toString().replaceAll('"', ''));
- if (mounted && height != null) setState(() => _contentHeight = height);
+ _controller.loadHtmlString(tabulate.html);
}
@override
@@ -69,17 +53,30 @@ class _TabulateViewState extends ConsumerState {
return AppScaffold(
title: StudentTabulateStrings.title,
padding: EdgeInsets.zero,
+ scrollable: false,
body: AsyncValueWidget(
value: tabulateState,
onRetry: () => ref.invalidate(tabulateViewModelProvider),
+
data: (tabulate) {
_loadIfNeeded(tabulate);
- return SizedBox(
- height: _contentHeight ?? MediaQuery.sizeOf(context).height,
- child: WebViewWidget(controller: _controller),
- );
+ return SizedBox(child: WebViewWidget(controller: _controller));
},
),
);
}
}
+
+extension on WebViewController {
+ // The tabulado is a fixed-width (765px) print layout, wider than any
+ // phone screen. iOS's WKWebView already auto-scales content like that to
+ // fit, like Safari does — but Android's WebView defaults to
+ // useWideViewPort=false, which makes it render at 1:1 scale and show only
+ // a cropped slice instead of the whole page shrunk to fit.
+ void _fitWideContentToScreen() {
+ final platform = this.platform;
+ if (platform is AndroidWebViewController) {
+ platform.setUseWideViewPort(true);
+ }
+ }
+}
diff --git a/lib/main.dart b/lib/main.dart
index b84558b..c6f3be5 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -6,6 +6,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'core/router/app_router.dart';
import 'core/storage/local_storage_providers.dart';
import 'core/theme/app_theme.dart';
+import 'features/remote_config/presentation/widgets/remote_config_gate.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
@@ -36,6 +37,8 @@ class MainApp extends ConsumerWidget {
routerConfig: router,
theme: AppTheme.light,
themeMode: ThemeMode.light,
+ builder: (context, child) =>
+ RemoteConfigGate(child: child ?? const SizedBox.shrink()),
);
}
}
diff --git a/pubspec.lock b/pubspec.lock
index cf0864c..087a60d 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -1099,7 +1099,7 @@ packages:
source: hosted
version: "4.14.1"
webview_flutter_android:
- dependency: transitive
+ dependency: "direct main"
description:
name: webview_flutter_android
sha256: "4de8b3d1ff4ebe1bdb42e68a5e4f809194a3cb0117a8f495f590004f00da3964"
diff --git a/pubspec.yaml b/pubspec.yaml
index 0f8c5ad..730de0e 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -25,6 +25,7 @@ dependencies:
shared_preferences: ^2.5.5
url_launcher: ^6.3.1
webview_flutter: ^4.14.1
+ webview_flutter_android: ^4.14.1
dev_dependencies:
flutter_test:
diff --git a/test/features/home/presentation/providers/app_version_provider_test.dart b/test/core/device/app_version_provider_test.dart
similarity index 80%
rename from test/features/home/presentation/providers/app_version_provider_test.dart
rename to test/core/device/app_version_provider_test.dart
index 0468a8a..8d5703e 100644
--- a/test/features/home/presentation/providers/app_version_provider_test.dart
+++ b/test/core/device/app_version_provider_test.dart
@@ -1,8 +1,8 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:package_info_plus/package_info_plus.dart';
-import 'package:univalle_app/features/home/presentation/providers/app_version_provider.dart';
+import 'package:univalle_app/core/device/app_version_provider.dart';
-import '../../../../helpers/container.dart';
+import '../../helpers/container.dart';
void main() {
setUp(() {
diff --git a/test/features/remote_config/data/datasources/remote_config_local_datasource_test.dart b/test/features/remote_config/data/datasources/remote_config_local_datasource_test.dart
new file mode 100644
index 0000000..4f91b88
--- /dev/null
+++ b/test/features/remote_config/data/datasources/remote_config_local_datasource_test.dart
@@ -0,0 +1,87 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:mocktail/mocktail.dart';
+import 'package:univalle_app/core/storage/local_storage_service.dart';
+import 'package:univalle_app/features/remote_config/data/datasources/remote_config_local_datasource.dart';
+
+class MockLocalStorageService extends Mock implements LocalStorageService {}
+
+void main() {
+ late MockLocalStorageService storage;
+ late RemoteConfigLocalDataSource dataSource;
+
+ setUp(() {
+ storage = MockLocalStorageService();
+ dataSource = RemoteConfigLocalDataSource(storage);
+ });
+
+ group('config snapshot', () {
+ test('returns null when nothing is cached', () async {
+ when(() => storage.getString('remote_config_snapshot'))
+ .thenAnswer((_) async => null);
+
+ expect(await dataSource.getConfigSnapshot(), isNull);
+ });
+
+ test(
+ 'returns null instead of throwing when the cache is corrupted',
+ () async {
+ when(() => storage.getString('remote_config_snapshot'))
+ .thenAnswer((_) async => 'not json');
+
+ expect(await dataSource.getConfigSnapshot(), isNull);
+ },
+ );
+
+ test('round-trips a saved snapshot', () async {
+ when(
+ () => storage.setString(
+ 'remote_config_snapshot',
+ any(that: contains('grades')),
+ ),
+ ).thenAnswer((_) async {});
+ when(() => storage.getString('remote_config_snapshot')).thenAnswer(
+ (_) async =>
+ '{"platformEnabled":true,'
+ '"maintenance":{"enabled":false,"title":"","message":""},'
+ '"update":{"latestVersion":"1.0.0","updateAvailable":false,'
+ '"updateRequired":false,"storeUrl":"","message":""},'
+ '"modules":[{"key":"grades","label":"Notas","icon":"notebook-text",'
+ '"route":"/grades","color":"#FF0000"}],'
+ '"quickAccess":["grades"],'
+ '"welcome":{"enabled":false,"title":"","description":"",'
+ '"imageUrl":null,"linkUrl":null},'
+ '"announcements":[]}',
+ );
+
+ await dataSource.saveConfigSnapshot({
+ 'modules': ['grades'],
+ });
+ final cached = await dataSource.getConfigSnapshot();
+
+ expect(cached, isNotNull);
+ expect(cached!.modules.single.key, 'grades');
+ });
+ });
+
+ test('dismissed update round-trips', () async {
+ when(() => storage.setString('remote_config_dismissed_update', '1.2.0'))
+ .thenAnswer((_) async {});
+ when(() => storage.getString('remote_config_dismissed_update'))
+ .thenAnswer((_) async => '1.2.0');
+
+ await dataSource.dismissUpdate('1.2.0');
+
+ expect(await dataSource.getDismissedUpdate(), '1.2.0');
+ });
+
+ test('seen welcome fingerprint round-trips', () async {
+ when(() => storage.setString('remote_config_seen_welcome', 'abc123'))
+ .thenAnswer((_) async {});
+ when(() => storage.getString('remote_config_seen_welcome'))
+ .thenAnswer((_) async => 'abc123');
+
+ await dataSource.markWelcomeSeen('abc123');
+
+ expect(await dataSource.getSeenWelcome(), 'abc123');
+ });
+}
diff --git a/test/features/remote_config/data/datasources/remote_config_remote_datasource_test.dart b/test/features/remote_config/data/datasources/remote_config_remote_datasource_test.dart
new file mode 100644
index 0000000..d2301b1
--- /dev/null
+++ b/test/features/remote_config/data/datasources/remote_config_remote_datasource_test.dart
@@ -0,0 +1,151 @@
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:dio/dio.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:mocktail/mocktail.dart';
+import 'package:univalle_app/core/error/exceptions.dart';
+import 'package:univalle_app/features/remote_config/data/datasources/remote_config_remote_datasource.dart';
+
+class MockDio extends Mock implements Dio {}
+
+Response