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: 18 additions & 0 deletions assets/svg/icons/newspaper.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions lib/core/constants/asset_paths.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,5 @@ abstract final class AssetPaths {
static const iconFine = 'assets/svg/icons/circle-dollar-sign.svg';
static const iconHistory = 'assets/svg/icons/history.svg';
static const iconGithub = 'assets/svg/icons/github.svg';
static const iconNewspaper = 'assets/svg/icons/newspaper.svg';
}
15 changes: 15 additions & 0 deletions lib/core/constants/news_constants.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
abstract final class NewsConstants {
static const String baseUrl = 'https://www.univalle.edu.co';
static const String listPath = '/agencia-de-noticias';

// K2 (the Joomla component behind the news agency) paginates via a
// "start" query param rather than a page number, and always returns this
// many items per page.
static const int itemsPerPage = 21;

static const String itemSelector = '.catItemView';
static const String titleLinkSelector = '.agencia-lst-item-titulo a';
static const String summarySelector = '.agencia-lista-texto';
static const String imageSelector = '.agencia-lst-img img';
static const String categorySelector = '.agencia-lista-categoria-item';
}
20 changes: 20 additions & 0 deletions lib/core/network/news_dio_provider.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import 'package:dio/dio.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

import '../constants/news_constants.dart';

part 'news_dio_provider.g.dart';

// The news agency page is public (no login), so unlike the other scraped
// sources this needs neither a cookie jar nor a persistent session.
@Riverpod(keepAlive: true)
Dio newsDio(Ref ref) {
return Dio(
BaseOptions(
baseUrl: NewsConstants.baseUrl,
responseType: ResponseType.plain,
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 @@ -7,6 +7,7 @@ import '../../features/digital_card/presentation/views/digital_card_view.dart';
import '../../features/home/presentation/views/all_shortcuts_view.dart';
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/resolution/presentation/views/resolution_view.dart';
import '../../features/restaurant/presentation/views/pending_payment_view.dart';
Expand Down Expand Up @@ -83,6 +84,10 @@ GoRouter appRouter(Ref ref) {
path: AppRoutes.library,
builder: (context, state) => const LibraryView(),
),
GoRoute(
path: AppRoutes.news,
builder: (context, state) => const NewsView(),
),
GoRoute(
path: AppRoutes.allFunctionalities,
builder: (context, state) => const AllShortcutsView(),
Expand Down
1 change: 1 addition & 0 deletions lib/core/router/app_routes.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ abstract final class AppRoutes {
static const schedule = '/schedule';
static const restaurant = '/restaurant';
static const library = '/library';
static const news = '/news';
static const pendingPayment = '/restaurant/pending-payment';
static const allFunctionalities = '/functionalities';
}
1 change: 1 addition & 0 deletions lib/features/home/home_strings.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ abstract final class HomeStrings {
static const scheduleShortcut = 'Horario de clases';
static const libraryShortcut = 'Biblioteca';
static const restaurantShortcut = 'Central (Meléndez)';
static const newsShortcut = 'Noticias';
static const comingSoon = 'Muy pronto podrás usar esta función.';
static const allFunctionalitiesTitle = 'Todas las funcionalidades';
static const repositoryLabel = 'code3743/univalle_app';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,5 +70,11 @@ List<QuickAccessItem> quickAccessItems(BuildContext context) {
accent: AppColors.univalleRed,
onTap: () => context.push(AppRoutes.restaurant),
),
QuickAccessItem(
iconAsset: AssetPaths.iconNewspaper,
label: HomeStrings.newsShortcut,
accent: AppColors.accentBlue,
onTap: () => context.push(AppRoutes.news),
),
];
}
66 changes: 66 additions & 0 deletions lib/features/news/data/datasources/news_remote_datasource.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import 'package:dio/dio.dart';
import 'package:html/dom.dart';
import 'package:html/parser.dart' show parse;

import '../../../../core/constants/news_constants.dart';
import '../../../../core/network/dio_exception_mapper.dart';
import '../models/news_article_model.dart';

class NewsRemoteDataSource {
final Dio _dio;
const NewsRemoteDataSource(this._dio);

Future<List<NewsArticleModel>> fetchNews({required int page}) async {
final response = await _run(
() => _dio.get(
NewsConstants.listPath,
queryParameters: page == 0
? null
: {'start': page * NewsConstants.itemsPerPage},
),
);

final document = parse(response.data as String);
return document
.querySelectorAll(NewsConstants.itemSelector)
.map(_parseArticle)
.whereType<NewsArticleModel>()
.toList();
}

NewsArticleModel? _parseArticle(Element item) {
final titleAnchor = item.querySelector(NewsConstants.titleLinkSelector);
final title = titleAnchor?.text.trim();
final href = titleAnchor?.attributes['href'];
if (title == null || title.isEmpty || href == null) return null;

final imageSrc = item
.querySelector(NewsConstants.imageSelector)
?.attributes['src'];
final category = item
.querySelector(NewsConstants.categorySelector)
?.text
.trim();

return NewsArticleModel(
title: title,
summary:
item.querySelector(NewsConstants.summarySelector)?.text.trim() ?? '',
sourceUrl: _toAbsoluteUrl(href),
imageUrl: imageSrc == null ? null : _toAbsoluteUrl(imageSrc),
category: category == null || category.isEmpty ? null : category,
);
}

String _toAbsoluteUrl(String path) {
return Uri.parse(NewsConstants.baseUrl).resolve(path).toString();
}

Future<T> _run<T>(Future<T> Function() request) async {
try {
return await request();
} on DioException catch (e) {
throw mapDioException(e);
}
}
}
25 changes: 25 additions & 0 deletions lib/features/news/data/models/news_article_model.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import '../../domain/entities/news_article.dart';

class NewsArticleModel {
final String title;
final String summary;
final String sourceUrl;
final String? imageUrl;
final String? category;

const NewsArticleModel({
required this.title,
required this.summary,
required this.sourceUrl,
this.imageUrl,
this.category,
});

NewsArticle toEntity() => NewsArticle(
title: title,
summary: summary,
sourceUrl: sourceUrl,
imageUrl: imageUrl,
category: category,
);
}
21 changes: 21 additions & 0 deletions lib/features/news/data/repositories/news_repository_impl.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import '../../../../core/error/exception_mapper.dart';
import '../../../../core/error/exceptions.dart';
import '../../../../core/error/result.dart';
import '../../domain/entities/news_article.dart';
import '../../domain/repositories/news_repository.dart';
import '../datasources/news_remote_datasource.dart';

class NewsRepositoryImpl implements NewsRepository {
final NewsRemoteDataSource _remote;
const NewsRepositoryImpl(this._remote);

@override
Future<Result<List<NewsArticle>>> getNews({required int page}) async {
try {
final articles = await _remote.fetchNews(page: page);
return Ok(articles.map((article) => article.toEntity()).toList());
} on AppException catch (e) {
return Err(mapExceptionToFailure(e));
}
}
}
15 changes: 15 additions & 0 deletions lib/features/news/domain/entities/news_article.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
class NewsArticle {
final String title;
final String summary;
final String sourceUrl;
final String? imageUrl;
final String? category;

const NewsArticle({
required this.title,
required this.summary,
required this.sourceUrl,
this.imageUrl,
this.category,
});
}
6 changes: 6 additions & 0 deletions lib/features/news/domain/repositories/news_repository.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import '../../../../core/error/result.dart';
import '../entities/news_article.dart';

abstract interface class NewsRepository {
Future<Result<List<NewsArticle>>> getNews({required int page});
}
12 changes: 12 additions & 0 deletions lib/features/news/domain/usecases/get_news_usecase.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import '../../../../core/error/result.dart';
import '../entities/news_article.dart';
import '../repositories/news_repository.dart';

class GetNewsUseCase {
final NewsRepository _repository;
const GetNewsUseCase(this._repository);

Future<Result<List<NewsArticle>>> call({required int page}) {
return _repository.getNews(page: page);
}
}
7 changes: 7 additions & 0 deletions lib/features/news/news_strings.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
abstract final class NewsStrings {
static const title = 'Noticias';
static const emptyMessage = 'No encontramos noticias por ahora.';
static const cannotOpenArticleLink = 'No pudimos abrir la noticia.';
static const loadMoreError =
'No pudimos cargar más noticias, sigue desplazándote para intentar de nuevo.';
}
24 changes: 24 additions & 0 deletions lib/features/news/presentation/providers/news_providers.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';

import '../../../../core/network/news_dio_provider.dart';
import '../../data/datasources/news_remote_datasource.dart';
import '../../data/repositories/news_repository_impl.dart';
import '../../domain/repositories/news_repository.dart';
import '../../domain/usecases/get_news_usecase.dart';

part 'news_providers.g.dart';

@Riverpod(keepAlive: true)
NewsRemoteDataSource newsRemoteDataSource(Ref ref) {
return NewsRemoteDataSource(ref.watch(newsDioProvider));
}

@Riverpod(keepAlive: true)
NewsRepository newsRepository(Ref ref) {
return NewsRepositoryImpl(ref.watch(newsRemoteDataSourceProvider));
}

@Riverpod(keepAlive: true)
GetNewsUseCase getNewsUseCase(Ref ref) {
return GetNewsUseCase(ref.watch(newsRepositoryProvider));
}
56 changes: 56 additions & 0 deletions lib/features/news/presentation/viewmodels/news_view_model.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';

import '../../domain/entities/news_article.dart';
import '../providers/news_providers.dart';

part 'news_view_model.g.dart';

class NewsFeed {
final List<NewsArticle> articles;
final bool hasMore;
const NewsFeed({required this.articles, required this.hasMore});
}

@riverpod
class NewsViewModel extends _$NewsViewModel {
int _page = 0;
bool _isLoadingMore = false;

@override
Future<NewsFeed> build() async {
_page = 0;
final result = await ref.read(getNewsUseCaseProvider).call(page: _page);
return result.fold(
onError: (failure) => throw failure,
onSuccess: (articles) =>
NewsFeed(articles: articles, hasMore: articles.isNotEmpty),
);
}

Future<void> 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(getNewsUseCaseProvider)
.call(page: nextPage);
result.fold(
onError: (failure) => throw failure,
onSuccess: (articles) {
_page = nextPage;
state = AsyncData(
NewsFeed(
articles: [...feed.articles, ...articles],
hasMore: articles.isNotEmpty,
),
);
},
);
} finally {
_isLoadingMore = false;
}
}
}
Loading
Loading