From 6f45c35b0847e5cfeff234705268bf9280bcf07e Mon Sep 17 00:00:00 2001 From: code3743 Date: Thu, 17 Sep 2026 14:13:33 -0500 Subject: [PATCH 1/9] feat: enhance CurrentPhotoUrl setter and update IdCardHeader layout for better program name display --- .../session/current_photo_url_provider.dart | 7 ++++++- .../student_id_card/id_card_header.dart | 19 +++++++++++-------- 2 files changed, 17 insertions(+), 9 deletions(-) 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), + ), ), ), ], From 949ccdec4b4fee8c664d12e7db29ca03dd6e69eb Mon Sep 17 00:00:00 2001 From: code3743 Date: Mon, 21 Sep 2026 20:17:29 -0500 Subject: [PATCH 2/9] feat: update SiraTabulateRemoteDataSource to absolutize resource URLs and improve WebView scaling on Android --- .../sira_tabulate_remote_datasource.dart | 50 +++++-------------- .../presentation/views/tabulate_view.dart | 45 ++++++++--------- .../sira_tabulate_remote_datasource_test.dart | 23 +++++++-- 3 files changed, 52 insertions(+), 66 deletions(-) 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/test/features/student_tabulate/data/datasources/sira_tabulate_remote_datasource_test.dart b/test/features/student_tabulate/data/datasources/sira_tabulate_remote_datasource_test.dart index f4f3876..351d004 100644 --- a/test/features/student_tabulate/data/datasources/sira_tabulate_remote_datasource_test.dart +++ b/test/features/student_tabulate/data/datasources/sira_tabulate_remote_datasource_test.dart @@ -34,17 +34,30 @@ void main() { group('fetchTabulate', () { test( - 'returns a mobile-ready document scaled from a real tabulado', + 'resolves root-relative image and stylesheet paths to absolute URLs', () async { when(() => dio.post(any(), data: any(named: 'data'))) .thenAnswer((_) async => _htmlResponse(reportHtml)); final result = await dataSource.fetchTabulate(username: '0000000-3743'); - expect(result, contains('id="mobileWrapper"')); - expect(result, contains('name="viewport"')); - // The real fixture's frame table is 765px wide. - expect(result, contains('calc(100vw / 765px)')); + expect( + result, + contains( + 'src="https://sira.univalle.edu.co/sra/paquetes/gui/temas/' + 'univalleRojoAmarillo/imagenes/logo.gif"', + ), + ); + expect( + result, + contains( + 'href="https://sira.univalle.edu.co/sra/paquetes/gui/temas/' + 'univalleRojoAmarillo/estilos.css"', + ), + ); + // The original print-layout markup is rendered as-is, not reflowed + // or wrapped for a mobile viewport. + expect(result, isNot(contains('id="mobileWrapper"'))); }, ); From 939439f73cdc0a483d7a2c55a2c9767ff6eeecfb Mon Sep 17 00:00:00 2001 From: code3743 Date: Mon, 21 Sep 2026 20:19:01 -0500 Subject: [PATCH 3/9] feat: add remote config support with announcements path and related providers --- lib/core/constants/api_constants.dart | 10 ++++++++++ lib/core/device/app_platform_provider.dart | 15 +++++++++++++++ lib/core/device/app_version_provider.dart | 11 +++++++++++ lib/core/network/api_dio_provider.dart | 20 ++++++++++++++++++++ lib/core/router/app_router.dart | 5 +++++ lib/core/router/app_routes.dart | 1 + 6 files changed, 62 insertions(+) create mode 100644 lib/core/constants/api_constants.dart create mode 100644 lib/core/device/app_platform_provider.dart create mode 100644 lib/core/device/app_version_provider.dart create mode 100644 lib/core/network/api_dio_provider.dart 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/core/device/app_version_provider.dart b/lib/core/device/app_version_provider.dart new file mode 100644 index 0000000..0d16587 --- /dev/null +++ b/lib/core/device/app_version_provider.dart @@ -0,0 +1,11 @@ +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'app_version_provider.g.dart'; + +/// App version shown in the Home footer, e.g. "0.1.0". +@riverpod +Future appVersion(Ref ref) async { + final info = await PackageInfo.fromPlatform(); + return info.version; +} diff --git a/lib/core/network/api_dio_provider.dart b/lib/core/network/api_dio_provider.dart new file mode 100644 index 0000000..d8e8058 --- /dev/null +++ b/lib/core/network/api_dio_provider.dart @@ -0,0 +1,20 @@ +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), + // Skips ngrok's browser-warning interstitial page while baseUrl points + // at a free-tier tunnel; harmless once it points at the real backend. + headers: const {'ngrok-skip-browser-warning': 'true'}, + ), + ); +} 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'; } From 2466a0e165c59740c673b7b3c85f1fad2bd503fc Mon Sep 17 00:00:00 2001 From: code3743 Date: Mon, 21 Sep 2026 20:19:50 -0500 Subject: [PATCH 4/9] feat: Implement remote configuration feature with app modules and announcements --- .../remote_config_local_datasource.dart | 40 +++++ .../remote_config_remote_datasource.dart | 58 +++++++ .../data/models/announcement_model.dart | 73 ++++++++ .../data/models/app_config_model.dart | 157 ++++++++++++++++++ .../data/models/app_module_model.dart | 38 +++++ .../data/models/welcome_banner_model.dart | 43 +++++ .../remote_config_repository_impl.dart | 69 ++++++++ .../domain/entities/announcement.dart | 13 ++ .../domain/entities/announcements_page.dart | 17 ++ .../domain/entities/app_config.dart | 48 ++++++ .../domain/entities/app_module.dart | 15 ++ .../domain/entities/welcome_banner.dart | 15 ++ .../remote_config_repository.dart | 18 ++ .../usecases/dismiss_update_usecase.dart | 10 ++ .../usecases/get_announcements_usecase.dart | 11 ++ .../usecases/get_app_config_usecase.dart | 13 ++ .../get_dismissed_update_usecase.dart | 9 + .../usecases/get_seen_welcome_usecase.dart | 9 + .../usecases/mark_welcome_seen_usecase.dart | 10 ++ .../providers/remote_config_providers.dart | 64 +++++++ .../presentation/utils/module_style.dart | 33 ++++ .../viewmodels/announcements_view_model.dart | 58 +++++++ .../viewmodels/home_overlays_view_model.dart | 57 +++++++ .../viewmodels/remote_config_view_model.dart | 26 +++ .../views/announcements_view.dart | 86 ++++++++++ .../presentation/views/maintenance_view.dart | 24 +++ .../views/update_required_view.dart | 36 ++++ .../widgets/announcement_card.dart | 83 +++++++++ .../widgets/blocking_message_view.dart | 58 +++++++ .../presentation/widgets/home_overlays.dart | 77 +++++++++ .../widgets/remote_config_gate.dart | 39 +++++ .../widgets/update_available_sheet.dart | 100 +++++++++++ .../widgets/welcome_banner_dialog.dart | 87 ++++++++++ .../remote_config/remote_config_strings.dart | 19 +++ 34 files changed, 1513 insertions(+) create mode 100644 lib/features/remote_config/data/datasources/remote_config_local_datasource.dart create mode 100644 lib/features/remote_config/data/datasources/remote_config_remote_datasource.dart create mode 100644 lib/features/remote_config/data/models/announcement_model.dart create mode 100644 lib/features/remote_config/data/models/app_config_model.dart create mode 100644 lib/features/remote_config/data/models/app_module_model.dart create mode 100644 lib/features/remote_config/data/models/welcome_banner_model.dart create mode 100644 lib/features/remote_config/data/repositories/remote_config_repository_impl.dart create mode 100644 lib/features/remote_config/domain/entities/announcement.dart create mode 100644 lib/features/remote_config/domain/entities/announcements_page.dart create mode 100644 lib/features/remote_config/domain/entities/app_config.dart create mode 100644 lib/features/remote_config/domain/entities/app_module.dart create mode 100644 lib/features/remote_config/domain/entities/welcome_banner.dart create mode 100644 lib/features/remote_config/domain/repositories/remote_config_repository.dart create mode 100644 lib/features/remote_config/domain/usecases/dismiss_update_usecase.dart create mode 100644 lib/features/remote_config/domain/usecases/get_announcements_usecase.dart create mode 100644 lib/features/remote_config/domain/usecases/get_app_config_usecase.dart create mode 100644 lib/features/remote_config/domain/usecases/get_dismissed_update_usecase.dart create mode 100644 lib/features/remote_config/domain/usecases/get_seen_welcome_usecase.dart create mode 100644 lib/features/remote_config/domain/usecases/mark_welcome_seen_usecase.dart create mode 100644 lib/features/remote_config/presentation/providers/remote_config_providers.dart create mode 100644 lib/features/remote_config/presentation/utils/module_style.dart create mode 100644 lib/features/remote_config/presentation/viewmodels/announcements_view_model.dart create mode 100644 lib/features/remote_config/presentation/viewmodels/home_overlays_view_model.dart create mode 100644 lib/features/remote_config/presentation/viewmodels/remote_config_view_model.dart create mode 100644 lib/features/remote_config/presentation/views/announcements_view.dart create mode 100644 lib/features/remote_config/presentation/views/maintenance_view.dart create mode 100644 lib/features/remote_config/presentation/views/update_required_view.dart create mode 100644 lib/features/remote_config/presentation/widgets/announcement_card.dart create mode 100644 lib/features/remote_config/presentation/widgets/blocking_message_view.dart create mode 100644 lib/features/remote_config/presentation/widgets/home_overlays.dart create mode 100644 lib/features/remote_config/presentation/widgets/remote_config_gate.dart create mode 100644 lib/features/remote_config/presentation/widgets/update_available_sheet.dart create mode 100644 lib/features/remote_config/presentation/widgets/welcome_banner_dialog.dart create mode 100644 lib/features/remote_config/remote_config_strings.dart 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..d277433 --- /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 +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..dd0672e --- /dev/null +++ b/lib/features/remote_config/presentation/widgets/welcome_banner_dialog.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.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) { + final colorScheme = Theme.of(context).colorScheme; + return Dialog( + backgroundColor: colorScheme.surface, + 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'; +} From 09a88df919b99865e8d4c6aa7beabe0e1db5a895 Mon Sep 17 00:00:00 2001 From: code3743 Date: Mon, 21 Sep 2026 20:20:03 -0500 Subject: [PATCH 5/9] feat: add unit tests for remote config data sources, models, and view models --- .../remote_config_local_datasource_test.dart | 87 ++++++++++ .../remote_config_remote_datasource_test.dart | 151 ++++++++++++++++++ .../data/models/announcement_model_test.dart | 35 ++++ .../data/models/app_config_model_test.dart | 88 ++++++++++ .../remote_config_repository_impl_test.dart | 93 +++++++++++ .../presentation/utils/module_style_test.dart | 31 ++++ .../remote_config_view_model_test.dart | 81 ++++++++++ 7 files changed, 566 insertions(+) create mode 100644 test/features/remote_config/data/datasources/remote_config_local_datasource_test.dart create mode 100644 test/features/remote_config/data/datasources/remote_config_remote_datasource_test.dart create mode 100644 test/features/remote_config/data/models/announcement_model_test.dart create mode 100644 test/features/remote_config/data/models/app_config_model_test.dart create mode 100644 test/features/remote_config/data/repositories/remote_config_repository_impl_test.dart create mode 100644 test/features/remote_config/presentation/utils/module_style_test.dart create mode 100644 test/features/remote_config/presentation/viewmodels/remote_config_view_model_test.dart 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> _jsonResponse(Map data) => + Response( + requestOptions: RequestOptions(path: ''), + statusCode: 200, + data: data, + ); + +void main() { + late MockDio dio; + late RemoteConfigRemoteDataSource dataSource; + late Map configStatusJson; + late Map modulesJson; + late Map welcomeJson; + late Map announcementsJson; + + setUpAll(() { + registerFallbackValue({}); + configStatusJson = jsonDecode( + File('test/fixtures/remote_config/app_config_status.json') + .readAsStringSync(), + ) as Map; + modulesJson = jsonDecode( + File('test/fixtures/remote_config/app_modules.json').readAsStringSync(), + ) as Map; + welcomeJson = jsonDecode( + File('test/fixtures/remote_config/app_welcome.json').readAsStringSync(), + ) as Map; + announcementsJson = jsonDecode( + File('test/fixtures/remote_config/announcements_page.json') + .readAsStringSync(), + ) as Map; + }); + + setUp(() { + dio = MockDio(); + dataSource = RemoteConfigRemoteDataSource(dio); + }); + + void stubAllEndpoints() { + when( + () => dio.get( + '/app/config', + queryParameters: any(named: 'queryParameters'), + ), + ).thenAnswer((_) async => _jsonResponse(configStatusJson)); + when( + () => dio.get( + '/app/modules', + queryParameters: any(named: 'queryParameters'), + ), + ).thenAnswer((_) async => _jsonResponse(modulesJson)); + when(() => dio.get('/app/welcome')) + .thenAnswer((_) async => _jsonResponse(welcomeJson)); + } + + group('fetchConfig', () { + test('fetches config, modules and welcome and merges them', () async { + stubAllEndpoints(); + + final model = await dataSource.fetchConfig( + platform: 'android', + version: '0.1.0', + ); + + verify( + () => dio.get( + '/app/config', + queryParameters: {'platform': 'android', 'version': '0.1.0'}, + ), + ).called(1); + verify( + () => dio.get('/app/modules', queryParameters: {'platform': 'android'}), + ).called(1); + verify(() => dio.get('/app/welcome')).called(1); + + expect(model.platformEnabled, isTrue); + expect(model.modules, isNotEmpty); + expect(model.quickAccess, ['grades', 'digital_card']); + expect(model.welcome.enabled, isTrue); + }); + + test( + 'maps a DioException from any of the endpoints to an AppException', + () { + stubAllEndpoints(); + when(() => dio.get('/app/welcome')).thenThrow( + DioException( + requestOptions: RequestOptions(path: ''), + type: DioExceptionType.connectionTimeout, + ), + ); + + expect( + () => dataSource.fetchConfig(platform: 'android', version: '0.1.0'), + throwsA(isA()), + ); + }, + ); + }); + + group('fetchAnnouncements', () { + test('sends the page as a query param and parses the envelope', () async { + when( + () => dio.get( + '/app/announcements', + queryParameters: any(named: 'queryParameters'), + ), + ).thenAnswer((_) async => _jsonResponse(announcementsJson)); + + final page = await dataSource.fetchAnnouncements(page: 1); + + verify(() => dio.get('/app/announcements', queryParameters: {'page': 1})) + .called(1); + expect(page.items, hasLength(2)); + }); + + test('maps a DioException to an AppException', () { + when( + () => dio.get( + '/app/announcements', + queryParameters: any(named: 'queryParameters'), + ), + ).thenThrow( + DioException( + requestOptions: RequestOptions(path: ''), + response: Response( + requestOptions: RequestOptions(path: ''), + statusCode: 500, + ), + type: DioExceptionType.badResponse, + ), + ); + + expect( + () => dataSource.fetchAnnouncements(page: 1), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/features/remote_config/data/models/announcement_model_test.dart b/test/features/remote_config/data/models/announcement_model_test.dart new file mode 100644 index 0000000..daf2a6b --- /dev/null +++ b/test/features/remote_config/data/models/announcement_model_test.dart @@ -0,0 +1,35 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:univalle_app/features/remote_config/data/models/announcement_model.dart'; + +void main() { + test('fromJson parses a nullable imageUrl', () { + final model = AnnouncementModel.fromJson({ + 'id': 1, + 'title': 'Título', + 'description': 'Descripción', + 'imageUrl': null, + }); + + expect(model.id, 1); + expect(model.imageUrl, isNull); + expect(model.toEntity().title, 'Título'); + }); + + test('AnnouncementsPageModel.fromJson parses the paginated envelope', () { + final json = jsonDecode( + File('test/fixtures/remote_config/announcements_page.json') + .readAsStringSync(), + ) as Map; + + final page = AnnouncementsPageModel.fromJson(json); + + expect(page.items, hasLength(2)); + expect(page.page, 1); + expect(page.limit, 20); + expect(page.total, 2); + expect(page.toEntity().hasMore, isFalse); + }); +} diff --git a/test/features/remote_config/data/models/app_config_model_test.dart b/test/features/remote_config/data/models/app_config_model_test.dart new file mode 100644 index 0000000..6814290 --- /dev/null +++ b/test/features/remote_config/data/models/app_config_model_test.dart @@ -0,0 +1,88 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:univalle_app/features/remote_config/data/models/app_config_model.dart'; + +void main() { + late Map fullJson; + late Map emptyJson; + late Map configStatusJson; + late Map modulesJson; + late Map welcomeJson; + + setUpAll(() { + fullJson = jsonDecode( + File('test/fixtures/remote_config/app_config.json').readAsStringSync(), + ) as Map; + emptyJson = jsonDecode( + File('test/fixtures/remote_config/app_config_empty.json') + .readAsStringSync(), + ) as Map; + configStatusJson = jsonDecode( + File('test/fixtures/remote_config/app_config_status.json') + .readAsStringSync(), + ) as Map; + modulesJson = jsonDecode( + File('test/fixtures/remote_config/app_modules.json').readAsStringSync(), + ) as Map; + welcomeJson = jsonDecode( + File('test/fixtures/remote_config/app_welcome.json').readAsStringSync(), + ) as Map; + }); + + test('fromJson parses a fully populated config', () { + final model = AppConfigModel.fromJson(fullJson); + + expect(model.platformEnabled, isTrue); + expect(model.maintenance.enabled, isFalse); + expect(model.update.latestVersion, '1.0.0'); + expect(model.modules, hasLength(2)); + expect(model.modules.first.key, 'grades'); + expect(model.modules.first.color, '#FF0000'); + expect(model.quickAccess, ['grades', 'digital_card']); + expect(model.welcome.enabled, isTrue); + expect(model.welcome.imageUrl, 'https://example.com/banner.png'); + }); + + test('fromJson parses nullable welcome fields and empty lists', () { + final model = AppConfigModel.fromJson(emptyJson); + + expect(model.platformEnabled, isFalse); + expect(model.maintenance.enabled, isTrue); + expect(model.modules, isEmpty); + expect(model.quickAccess, isEmpty); + expect(model.welcome.imageUrl, isNull); + expect(model.welcome.linkUrl, isNull); + }); + + test('toJson then fromJson round-trips to an equivalent model', () { + final original = AppConfigModel.fromJson(fullJson); + + final roundTripped = AppConfigModel.fromJson(original.toJson()); + + expect(roundTripped.toJson(), original.toJson()); + }); + + test('toEntity maps every field, including nested nullables', () { + final entity = AppConfigModel.fromJson(fullJson).toEntity(); + + expect(entity.platformEnabled, isTrue); + expect(entity.modules.first.route, '/grades'); + expect(entity.welcome.linkUrl, 'https://example.com/evento'); + }); + + test('fromParts merges the backend\'s split endpoint responses', () { + final model = AppConfigModel.fromParts( + config: configStatusJson, + modules: modulesJson, + welcome: welcomeJson, + ); + + expect(model.platformEnabled, isTrue); + expect(model.update.latestVersion, '1.0.0'); + expect(model.modules, hasLength(2)); + expect(model.quickAccess, ['grades', 'digital_card']); + expect(model.welcome.title, 'Semana de la ciencia'); + }); +} diff --git a/test/features/remote_config/data/repositories/remote_config_repository_impl_test.dart b/test/features/remote_config/data/repositories/remote_config_repository_impl_test.dart new file mode 100644 index 0000000..0b4e073 --- /dev/null +++ b/test/features/remote_config/data/repositories/remote_config_repository_impl_test.dart @@ -0,0 +1,93 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:univalle_app/core/error/exceptions.dart'; +import 'package:univalle_app/core/error/failures.dart'; +import 'package:univalle_app/core/error/result.dart'; +import 'package:univalle_app/features/remote_config/data/datasources/remote_config_local_datasource.dart'; +import 'package:univalle_app/features/remote_config/data/datasources/remote_config_remote_datasource.dart'; +import 'package:univalle_app/features/remote_config/data/models/app_config_model.dart'; +import 'package:univalle_app/features/remote_config/data/repositories/remote_config_repository_impl.dart'; +import 'package:univalle_app/features/remote_config/domain/entities/app_config.dart'; + +class MockRemoteConfigRemoteDataSource extends Mock + implements RemoteConfigRemoteDataSource {} + +class MockRemoteConfigLocalDataSource extends Mock + implements RemoteConfigLocalDataSource {} + +void main() { + late MockRemoteConfigRemoteDataSource remote; + late MockRemoteConfigLocalDataSource local; + late RemoteConfigRepositoryImpl repository; + late AppConfigModel configModel; + + setUpAll(() { + registerFallbackValue({}); + configModel = AppConfigModel.fromJson( + jsonDecode( + File('test/fixtures/remote_config/app_config.json').readAsStringSync(), + ) as Map, + ); + }); + + setUp(() { + remote = MockRemoteConfigRemoteDataSource(); + local = MockRemoteConfigLocalDataSource(); + repository = RemoteConfigRepositoryImpl(remote, local); + }); + + group('getConfig', () { + test( + 'caches and returns the config when the remote call succeeds', + () async { + when(() => remote.fetchConfig(platform: 'android', version: '0.1.0')) + .thenAnswer((_) async => configModel); + when(() => local.saveConfigSnapshot(any())).thenAnswer((_) async {}); + + final result = await repository.getConfig( + platform: 'android', + version: '0.1.0', + ); + + expect(result, isA>()); + verify(() => local.saveConfigSnapshot(configModel.toJson())).called(1); + }, + ); + + test( + 'falls back to the cached config when the remote call fails', + () async { + when(() => remote.fetchConfig(platform: 'android', version: '0.1.0')) + .thenThrow(const NetworkException(message: 'no connection')); + when(() => local.getConfigSnapshot()) + .thenAnswer((_) async => configModel); + + final result = await repository.getConfig( + platform: 'android', + version: '0.1.0', + ); + + expect(result, isA>()); + }, + ); + + test( + 'returns Err when the remote call fails and there is no cache', + () async { + when(() => remote.fetchConfig(platform: 'android', version: '0.1.0')) + .thenThrow(const NetworkException(message: 'no connection')); + when(() => local.getConfigSnapshot()).thenAnswer((_) async => null); + + final result = await repository.getConfig( + platform: 'android', + version: '0.1.0', + ); + + expect((result as Err).failure, isA()); + }, + ); + }); +} diff --git a/test/features/remote_config/presentation/utils/module_style_test.dart b/test/features/remote_config/presentation/utils/module_style_test.dart new file mode 100644 index 0000000..18f2034 --- /dev/null +++ b/test/features/remote_config/presentation/utils/module_style_test.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:univalle_app/core/constants/asset_paths.dart'; +import 'package:univalle_app/core/theme/app_colors.dart'; +import 'package:univalle_app/features/remote_config/presentation/utils/module_style.dart'; + +void main() { + group('moduleIconAsset', () { + test('resolves a known backend icon name to its local asset', () { + expect(moduleIconAsset('notebook-text'), AssetPaths.iconNotebook); + }); + + test('falls back to a generic asset for an unknown name', () { + expect(moduleIconAsset('some-future-icon'), AssetPaths.iconLink); + }); + }); + + group('moduleColor', () { + test('parses a valid #RRGGBB hex string', () { + expect(moduleColor('#2563EB'), const Color(0xFF2563EB)); + }); + + test('falls back to univalleRed for a malformed value', () { + expect(moduleColor('not-a-color'), AppColors.univalleRed); + }); + + test('falls back to univalleRed for an empty string', () { + expect(moduleColor(''), AppColors.univalleRed); + }); + }); +} diff --git a/test/features/remote_config/presentation/viewmodels/remote_config_view_model_test.dart b/test/features/remote_config/presentation/viewmodels/remote_config_view_model_test.dart new file mode 100644 index 0000000..f588565 --- /dev/null +++ b/test/features/remote_config/presentation/viewmodels/remote_config_view_model_test.dart @@ -0,0 +1,81 @@ +import 'package:flutter_riverpod/misc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:univalle_app/core/device/app_platform_provider.dart'; +import 'package:univalle_app/core/error/exceptions.dart'; +import 'package:univalle_app/core/error/failures.dart'; +import 'package:univalle_app/core/error/result.dart'; +import 'package:univalle_app/features/remote_config/domain/entities/app_config.dart'; +import 'package:univalle_app/features/remote_config/domain/entities/welcome_banner.dart' + as domain; +import 'package:univalle_app/features/remote_config/domain/repositories/remote_config_repository.dart'; +import 'package:univalle_app/features/remote_config/presentation/providers/remote_config_providers.dart'; +import 'package:univalle_app/features/remote_config/presentation/viewmodels/remote_config_view_model.dart'; + +import '../../../../helpers/container.dart'; + +class MockRemoteConfigRepository extends Mock + implements RemoteConfigRepository {} + +AppConfig _config() => const AppConfig( + platformEnabled: true, + maintenance: MaintenanceStatus(enabled: false, title: '', message: ''), + update: UpdateStatus( + latestVersion: '1.0.0', + updateAvailable: false, + updateRequired: false, + storeUrl: '', + message: '', + ), + modules: [], + quickAccess: [], + welcome: domain.WelcomeBanner(enabled: false, title: '', description: ''), +); + +void main() { + late MockRemoteConfigRepository repository; + + setUp(() { + repository = MockRemoteConfigRepository(); + PackageInfo.setMockInitialValues( + appName: 'Univalle App', + packageName: 'co.edu.univalle.univalle_app', + version: '0.1.0', + buildNumber: '1', + buildSignature: '', + ); + }); + + List overridesWith(RemoteConfigRepository repo) => [ + remoteConfigRepositoryProvider.overrideWithValue(repo), + appPlatformProvider.overrideWithValue('android'), + ]; + + test('returns the config on success', () async { + when(() => repository.getConfig(platform: 'android', version: '0.1.0')) + .thenAnswer((_) async => Ok(_config())); + final container = createContainer(overrides: overridesWith(repository)); + + final config = await container.read(remoteConfigViewModelProvider.future); + + expect(config, isNotNull); + expect(config!.platformEnabled, isTrue); + }); + + test('fails open: resolves to null instead of throwing when the repository errors', () async { + when(() => repository.getConfig(platform: 'android', version: '0.1.0')) + .thenAnswer( + (_) async => Err( + NetworkFailure( + message: const NetworkException(message: 'down').message, + ), + ), + ); + final container = createContainer(overrides: overridesWith(repository)); + + final config = await container.read(remoteConfigViewModelProvider.future); + + expect(config, isNull); + }); +} From d2da2111b8fa887d9dbfa1ecc4a71a06569f96e2 Mon Sep 17 00:00:00 2001 From: code3743 Date: Mon, 21 Sep 2026 20:20:11 -0500 Subject: [PATCH 6/9] feat: add unit test for app version provider to verify platform version reporting --- .../device/app_version_provider_test.dart | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 test/core/device/app_version_provider_test.dart diff --git a/test/core/device/app_version_provider_test.dart b/test/core/device/app_version_provider_test.dart new file mode 100644 index 0000000..8d5703e --- /dev/null +++ b/test/core/device/app_version_provider_test.dart @@ -0,0 +1,25 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:univalle_app/core/device/app_version_provider.dart'; + +import '../../helpers/container.dart'; + +void main() { + setUp(() { + PackageInfo.setMockInitialValues( + appName: 'Univalle App', + packageName: 'co.edu.univalle.univalle_app', + version: '1.2.3', + buildNumber: '7', + buildSignature: '', + ); + }); + + test('reads the version reported by the platform', () async { + final container = createContainer(); + + final version = await container.read(appVersionProvider.future); + + expect(version, '1.2.3'); + }); +} From d51c2c39a33c56a53307be379ea7188cd9464202 Mon Sep 17 00:00:00 2001 From: code3743 Date: Mon, 21 Sep 2026 20:20:36 -0500 Subject: [PATCH 7/9] feat: enhance remote configuration with announcements and update home view components --- README.md | 8 +- lib/features/home/home_strings.dart | 2 + .../providers/app_version_provider.dart | 11 --- .../views/all_shortcuts_view.dart | 10 ++- .../home/presentation/views/home_view.dart | 74 +++---------------- .../presentation/widgets/home_footer.dart | 2 +- .../presentation/widgets/home_greeting.dart | 34 +++++++++ .../widgets/home_quick_access.dart | 69 +++++++++++++++++ .../widgets/quick_access_items.dart | 71 +++++++++++++++++- lib/main.dart | 3 + pubspec.lock | 2 +- pubspec.yaml | 1 + .../providers/app_version_provider_test.dart | 25 ------- .../remote_config/announcements_page.json | 19 +++++ test/fixtures/remote_config/app_config.json | 35 +++++++++ .../remote_config/app_config_empty.json | 24 ++++++ .../remote_config/app_config_status.json | 11 +++ test/fixtures/remote_config/app_modules.json | 19 +++++ test/fixtures/remote_config/app_welcome.json | 7 ++ 19 files changed, 319 insertions(+), 108 deletions(-) delete mode 100644 lib/features/home/presentation/providers/app_version_provider.dart create mode 100644 lib/features/home/presentation/widgets/home_greeting.dart create mode 100644 lib/features/home/presentation/widgets/home_quick_access.dart delete mode 100644 test/features/home/presentation/providers/app_version_provider_test.dart create mode 100644 test/fixtures/remote_config/announcements_page.json create mode 100644 test/fixtures/remote_config/app_config.json create mode 100644 test/fixtures/remote_config/app_config_empty.json create mode 100644 test/fixtures/remote_config/app_config_status.json create mode 100644 test/fixtures/remote_config/app_modules.json create mode 100644 test/fixtures/remote_config/app_welcome.json diff --git a/README.md b/README.md index b134ded..5b6eb34 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 | 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/providers/app_version_provider.dart b/lib/features/home/presentation/providers/app_version_provider.dart deleted file mode 100644 index 0d16587..0000000 --- a/lib/features/home/presentation/providers/app_version_provider.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:package_info_plus/package_info_plus.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -part 'app_version_provider.g.dart'; - -/// App version shown in the Home footer, e.g. "0.1.0". -@riverpod -Future appVersion(Ref ref) async { - final info = await PackageInfo.fromPlatform(); - return info.version; -} 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/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/features/home/presentation/providers/app_version_provider_test.dart deleted file mode 100644 index 0468a8a..0000000 --- a/test/features/home/presentation/providers/app_version_provider_test.dart +++ /dev/null @@ -1,25 +0,0 @@ -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 '../../../../helpers/container.dart'; - -void main() { - setUp(() { - PackageInfo.setMockInitialValues( - appName: 'Univalle App', - packageName: 'co.edu.univalle.univalle_app', - version: '1.2.3', - buildNumber: '7', - buildSignature: '', - ); - }); - - test('reads the version reported by the platform', () async { - final container = createContainer(); - - final version = await container.read(appVersionProvider.future); - - expect(version, '1.2.3'); - }); -} diff --git a/test/fixtures/remote_config/announcements_page.json b/test/fixtures/remote_config/announcements_page.json new file mode 100644 index 0000000..e16dd9e --- /dev/null +++ b/test/fixtures/remote_config/announcements_page.json @@ -0,0 +1,19 @@ +{ + "items": [ + { + "id": 1, + "title": "Nueva funcionalidad", + "description": "Ya puedes ver tus notas", + "imageUrl": null + }, + { + "id": 2, + "title": "Otra novedad", + "description": "Descripción de la novedad", + "imageUrl": "https://example.com/a.png" + } + ], + "page": 1, + "limit": 20, + "total": 2 +} diff --git a/test/fixtures/remote_config/app_config.json b/test/fixtures/remote_config/app_config.json new file mode 100644 index 0000000..27869ad --- /dev/null +++ b/test/fixtures/remote_config/app_config.json @@ -0,0 +1,35 @@ +{ + "platformEnabled": true, + "maintenance": { "enabled": false, "title": "", "message": "" }, + "update": { + "latestVersion": "1.0.0", + "updateAvailable": false, + "updateRequired": false, + "storeUrl": "https://play.google.com", + "message": "" + }, + "modules": [ + { + "key": "grades", + "label": "Historial de notas", + "icon": "notebook-text", + "route": "/grades", + "color": "#FF0000" + }, + { + "key": "digital_card", + "label": "Carné estudiantil", + "icon": "id-card", + "route": "/digital-card", + "color": "#7C3AED" + } + ], + "quickAccess": ["grades", "digital_card"], + "welcome": { + "enabled": true, + "title": "Semana de la ciencia", + "description": "Participa del evento", + "imageUrl": "https://example.com/banner.png", + "linkUrl": "https://example.com/evento" + } +} diff --git a/test/fixtures/remote_config/app_config_empty.json b/test/fixtures/remote_config/app_config_empty.json new file mode 100644 index 0000000..539107e --- /dev/null +++ b/test/fixtures/remote_config/app_config_empty.json @@ -0,0 +1,24 @@ +{ + "platformEnabled": false, + "maintenance": { + "enabled": true, + "title": "En mantenimiento", + "message": "Volvemos pronto" + }, + "update": { + "latestVersion": "0.0.0", + "updateAvailable": false, + "updateRequired": false, + "storeUrl": "", + "message": "" + }, + "modules": [], + "quickAccess": [], + "welcome": { + "enabled": false, + "title": "", + "description": "", + "imageUrl": null, + "linkUrl": null + } +} diff --git a/test/fixtures/remote_config/app_config_status.json b/test/fixtures/remote_config/app_config_status.json new file mode 100644 index 0000000..1303ed8 --- /dev/null +++ b/test/fixtures/remote_config/app_config_status.json @@ -0,0 +1,11 @@ +{ + "platformEnabled": true, + "maintenance": { "enabled": false, "title": "", "message": "" }, + "update": { + "latestVersion": "1.0.0", + "updateAvailable": false, + "updateRequired": false, + "storeUrl": "https://play.google.com", + "message": "" + } +} diff --git a/test/fixtures/remote_config/app_modules.json b/test/fixtures/remote_config/app_modules.json new file mode 100644 index 0000000..b26c8ca --- /dev/null +++ b/test/fixtures/remote_config/app_modules.json @@ -0,0 +1,19 @@ +{ + "items": [ + { + "key": "grades", + "label": "Historial de notas", + "icon": "notebook-text", + "route": "/grades", + "color": "#FF0000" + }, + { + "key": "digital_card", + "label": "Carné estudiantil", + "icon": "id-card", + "route": "/digital-card", + "color": "#7C3AED" + } + ], + "quickAccess": ["grades", "digital_card"] +} diff --git a/test/fixtures/remote_config/app_welcome.json b/test/fixtures/remote_config/app_welcome.json new file mode 100644 index 0000000..24fb08c --- /dev/null +++ b/test/fixtures/remote_config/app_welcome.json @@ -0,0 +1,7 @@ +{ + "enabled": true, + "title": "Semana de la ciencia", + "description": "Participa del evento", + "imageUrl": "https://example.com/banner.png", + "linkUrl": "https://example.com/evento" +} From e39b53621eb970002013966846aba7d41079a791 Mon Sep 17 00:00:00 2001 From: code3743 Date: Mon, 21 Sep 2026 20:24:18 -0500 Subject: [PATCH 8/9] feat: remove ngrok browser warning header from Dio configuration --- lib/core/network/api_dio_provider.dart | 3 --- 1 file changed, 3 deletions(-) diff --git a/lib/core/network/api_dio_provider.dart b/lib/core/network/api_dio_provider.dart index d8e8058..15b27b9 100644 --- a/lib/core/network/api_dio_provider.dart +++ b/lib/core/network/api_dio_provider.dart @@ -12,9 +12,6 @@ Dio apiDio(Ref ref) { baseUrl: ApiConstants.baseUrl, connectTimeout: const Duration(seconds: 15), receiveTimeout: const Duration(seconds: 30), - // Skips ngrok's browser-warning interstitial page while baseUrl points - // at a free-tier tunnel; harmless once it points at the real backend. - headers: const {'ngrok-skip-browser-warning': 'true'}, ), ); } From 9baff57da97dd93e4a25ad6153f0abb307b40854 Mon Sep 17 00:00:00 2001 From: code3743 Date: Mon, 21 Sep 2026 20:47:58 -0500 Subject: [PATCH 9/9] feat: update HomeOverlaysViewModel to keep alive and modify WelcomeBannerDialog background color --- README.md | 10 +++++++++- .../viewmodels/home_overlays_view_model.dart | 2 +- .../presentation/widgets/welcome_banner_dialog.dart | 4 ++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 5b6eb34..621a609 100644 --- a/README.md +++ b/README.md @@ -184,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/features/remote_config/presentation/viewmodels/home_overlays_view_model.dart b/lib/features/remote_config/presentation/viewmodels/home_overlays_view_model.dart index d277433..9e1f049 100644 --- a/lib/features/remote_config/presentation/viewmodels/home_overlays_view_model.dart +++ b/lib/features/remote_config/presentation/viewmodels/home_overlays_view_model.dart @@ -12,7 +12,7 @@ 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 +@Riverpod(keepAlive: true) class HomeOverlaysViewModel extends _$HomeOverlaysViewModel { @override void build() {} diff --git a/lib/features/remote_config/presentation/widgets/welcome_banner_dialog.dart b/lib/features/remote_config/presentation/widgets/welcome_banner_dialog.dart index dd0672e..ad03a0b 100644 --- a/lib/features/remote_config/presentation/widgets/welcome_banner_dialog.dart +++ b/lib/features/remote_config/presentation/widgets/welcome_banner_dialog.dart @@ -1,4 +1,5 @@ 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'; @@ -26,9 +27,8 @@ class WelcomeBannerDialog extends StatelessWidget { @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; return Dialog( - backgroundColor: colorScheme.surface, + backgroundColor: AppColors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), clipBehavior: Clip.antiAlias, child: Column(