Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
```
Expand Down Expand Up @@ -100,6 +101,7 @@ flowchart LR
REST["Restaurante universitario<br/>(tiquetes de almuerzo)"]
LIB["Biblioteca OPAC<br/>(cuenta, préstamos)"]
EVAL["Evaluación docente"]
NEWS["Agencia de Noticias Univalle<br/>(noticias)"]

App --> SIRA
App --> SIRA1
Expand All @@ -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:
Expand All @@ -132,7 +135,7 @@ sequenceDiagram
VM-->>U: AsyncValue<Entity> 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

Expand All @@ -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 |
Expand Down Expand Up @@ -180,9 +184,17 @@ El proyecto usa Riverpod con codegen, así que hace falta generar el código (`*

```bash
dart run build_runner build --delete-conflicting-outputs
flutter run
```

La app lee la URL del backend de configuración remota (`lib/core/constants/api_constants.dart`) desde la variable de entorno `API_BASE_URL`, así que hay que pasarla con `--dart-define` al correr o compilar:

```bash
flutter run --dart-define=API_BASE_URL=https://tu-backend/api
```

> [!NOTE]
> Si corrés desde VS Code, configurá el mismo `--dart-define` como `toolArgs` en `.vscode/launch.json` (no versionado) para no tener que pasarlo a mano en cada debug/run.

## Compilación

```bash
Expand Down
10 changes: 10 additions & 0 deletions lib/core/constants/api_constants.dart
Original file line number Diff line number Diff line change
@@ -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';
}
15 changes: 15 additions & 0 deletions lib/core/device/app_platform_provider.dart
Original file line number Diff line number Diff line change
@@ -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',
};
}
17 changes: 17 additions & 0 deletions lib/core/network/api_dio_provider.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import 'package:dio/dio.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

import '../constants/api_constants.dart';

part 'api_dio_provider.g.dart';

@Riverpod(keepAlive: true)
Dio apiDio(Ref ref) {
return Dio(
BaseOptions(
baseUrl: ApiConstants.baseUrl,
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 30),
),
);
}
5 changes: 5 additions & 0 deletions lib/core/router/app_router.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(),
),
],
);
}
1 change: 1 addition & 0 deletions lib/core/router/app_routes.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
7 changes: 6 additions & 1 deletion lib/core/session/current_photo_url_provider.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'package:flutter/painting.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'current_photo_url_provider.g.dart';
Expand All @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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),
),
),
),
],
Expand Down
2 changes: 2 additions & 0 deletions lib/features/home/home_strings.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
10 changes: 7 additions & 3 deletions lib/features/home/presentation/views/all_shortcuts_view.dart
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand Down
74 changes: 10 additions & 64 deletions lib/features/home/presentation/views/home_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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});
Expand All @@ -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(
Expand All @@ -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,
Expand Down Expand Up @@ -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(),
],
Expand Down
2 changes: 1 addition & 1 deletion lib/features/home/presentation/widgets/home_footer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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});
Expand Down
34 changes: 34 additions & 0 deletions lib/features/home/presentation/widgets/home_greeting.dart
Original file line number Diff line number Diff line change
@@ -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),
),
],
);
}
}
Loading
Loading