Skip to content

Feature entity details page #29

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 23 commits into from
May 31, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
0147a64
feat: Navigate to category/source details
fulleni May 31, 2025
735ad25
feat(tile): navigate to source/category details
fulleni May 31, 2025
0d25b70
feat: add entity details routes
fulleni May 31, 2025
59b965c
feat: add details page localization
fulleni May 31, 2025
67a9470
feat(search): navigate to source details page
fulleni May 31, 2025
0441379
feat(search): navigate to category details
fulleni May 31, 2025
62e0583
feat: Implement entity details page
fulleni May 31, 2025
e9b5d01
feat: add entity type enum
fulleni May 31, 2025
f53ed5d
feat(entity_details): create entity details state
fulleni May 31, 2025
c670fd0
feat(entity_details): add events for bloc
fulleni May 31, 2025
946db0f
feat: Implement EntityDetailsBloc
fulleni May 31, 2025
b1979f5
feat(account): navigate to source details
fulleni May 31, 2025
8ec05da
feat(account): navigate to category details
fulleni May 31, 2025
f106c4f
refactor(router): share AccountBloc instance
fulleni May 31, 2025
df67eda
feat(entity_details): revamp entity details page
fulleni May 31, 2025
e73571b
feat(headline): link source/category to details
fulleni May 31, 2025
77a9a10
feat(router): inject accountBloc into details pages
fulleni May 31, 2025
1c864c3
refactor(details): Simplify UI elements
fulleni May 31, 2025
87bc809
feat: add global article details route
fulleni May 31, 2025
859c255
feat(router): Add global article details route
fulleni May 31, 2025
3da1508
refactor(entity_details): use global article route
fulleni May 31, 2025
7864a19
style: misc
fulleni May 31, 2025
b00ff45
lint: misc
fulleni May 31, 2025
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
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:ht_main/account/bloc/account_bloc.dart';
import 'package:ht_main/entity_details/view/entity_details_page.dart'; // Added
import 'package:ht_main/l10n/l10n.dart';
import 'package:ht_main/router/routes.dart';
import 'package:ht_main/shared/constants/app_spacing.dart';
Expand Down Expand Up @@ -111,6 +112,13 @@ class FollowedCategoriesListPage extends StatelessWidget {
)
: const Icon(Icons.category_outlined),
title: Text(category.name),
onTap: () {
// Added onTap for navigation
context.push(
Routes.categoryDetails,
extra: EntityDetailsPageArguments(entity: category),
);
},
trailing: IconButton(
icon: Icon(
Icons.remove_circle_outline,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:ht_main/account/bloc/account_bloc.dart';
import 'package:ht_main/entity_details/view/entity_details_page.dart'; // Added
import 'package:ht_main/l10n/l10n.dart';
import 'package:ht_main/router/routes.dart';
import 'package:ht_main/shared/constants/app_spacing.dart';
Expand Down Expand Up @@ -95,6 +96,13 @@ class FollowedSourcesListPage extends StatelessWidget {
margin: const EdgeInsets.only(bottom: AppSpacing.sm),
child: ListTile(
title: Text(source.name),
onTap: () {
// Added onTap for navigation
context.push(
Routes.sourceDetails,
extra: EntityDetailsPageArguments(entity: source),
);
},
trailing: IconButton(
icon: Icon(
Icons.remove_circle_outline,
Expand Down
3 changes: 0 additions & 3 deletions lib/account/view/saved_headlines_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,6 @@ class SavedHeadlinesPage extends StatelessWidget {
),
trailing: trailingButton,
);
break;
case HeadlineImageStyle.smallThumbnail:
tile = HeadlineTileImageStart(
headline: headline,
Expand All @@ -115,7 +114,6 @@ class SavedHeadlinesPage extends StatelessWidget {
),
trailing: trailingButton,
);
break;
case HeadlineImageStyle.largeThumbnail:
tile = HeadlineTileImageTop(
headline: headline,
Expand All @@ -127,7 +125,6 @@ class SavedHeadlinesPage extends StatelessWidget {
),
trailing: trailingButton,
);
break;
}
return tile;
},
Expand Down
295 changes: 295 additions & 0 deletions lib/entity_details/bloc/entity_details_bloc.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,295 @@
import 'dart:async';

import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:ht_main/account/bloc/account_bloc.dart'; // Corrected import
import 'package:ht_data_repository/ht_data_repository.dart';
import 'package:ht_main/entity_details/models/entity_type.dart';
import 'package:ht_shared/ht_shared.dart';

part 'entity_details_event.dart';
part 'entity_details_state.dart';

class EntityDetailsBloc extends Bloc<EntityDetailsEvent, EntityDetailsState> {
EntityDetailsBloc({
required HtDataRepository<Headline> headlinesRepository,
required HtDataRepository<Category> categoryRepository,
required HtDataRepository<Source> sourceRepository,
required AccountBloc accountBloc, // Changed to AccountBloc
}) : _headlinesRepository = headlinesRepository,
_categoryRepository = categoryRepository,
_sourceRepository = sourceRepository,
_accountBloc = accountBloc,
super(const EntityDetailsState()) {
on<EntityDetailsLoadRequested>(_onEntityDetailsLoadRequested);
on<EntityDetailsToggleFollowRequested>(
_onEntityDetailsToggleFollowRequested,
);
on<EntityDetailsLoadMoreHeadlinesRequested>(
_onEntityDetailsLoadMoreHeadlinesRequested,
);
on<_EntityDetailsUserPreferencesChanged>(
_onEntityDetailsUserPreferencesChanged,
);

// Listen to AccountBloc for changes in user preferences
_accountBlocSubscription = _accountBloc.stream.listen((accountState) {
if (accountState.preferences != null) {
add(_EntityDetailsUserPreferencesChanged(accountState.preferences!));
}
});
}

final HtDataRepository<Headline> _headlinesRepository;
final HtDataRepository<Category> _categoryRepository;
final HtDataRepository<Source> _sourceRepository;
final AccountBloc _accountBloc; // Changed to AccountBloc
late final StreamSubscription<AccountState> _accountBlocSubscription;

static const _headlinesLimit = 10;

Future<void> _onEntityDetailsLoadRequested(
EntityDetailsLoadRequested event,
Emitter<EntityDetailsState> emit,
) async {
emit(
state.copyWith(status: EntityDetailsStatus.loading, clearEntity: true),
);

dynamic entityToLoad = event.entity;
var entityTypeToLoad = event.entityType;

try {
// 1. Determine/Fetch Entity
if (entityToLoad == null &&
event.entityId != null &&
event.entityType != null) {
entityTypeToLoad = event.entityType; // Ensure type is set
if (event.entityType == EntityType.category) {
entityToLoad = await _categoryRepository.read(id: event.entityId!);
} else if (event.entityType == EntityType.source) {
entityToLoad = await _sourceRepository.read(id: event.entityId!);
} else {
throw Exception('Unknown entity type for ID fetch');
}
} else if (entityToLoad != null) {
// If entity is directly provided, determine its type
if (entityToLoad is Category) {
entityTypeToLoad = EntityType.category;
} else if (entityToLoad is Source) {
entityTypeToLoad = EntityType.source;
} else {
throw Exception('Provided entity is of unknown type');
}
}

if (entityToLoad == null || entityTypeToLoad == null) {
emit(
state.copyWith(
status: EntityDetailsStatus.failure,
errorMessage: 'Entity could not be determined or loaded.',
),
);
return;
}

// 2. Fetch Initial Headlines
final queryParams = <String, dynamic>{};
if (entityTypeToLoad == EntityType.category) {
queryParams['categories'] = (entityToLoad as Category).id;
} else if (entityTypeToLoad == EntityType.source) {
queryParams['sources'] = (entityToLoad as Source).id;
}

final headlinesResponse = await _headlinesRepository.readAllByQuery(
queryParams,
limit: _headlinesLimit,
);

// 3. Determine isFollowing status
var isCurrentlyFollowing = false;
final currentAccountState = _accountBloc.state;
if (currentAccountState.preferences != null) {
if (entityTypeToLoad == EntityType.category &&
entityToLoad is Category) {
isCurrentlyFollowing = currentAccountState
.preferences!
.followedCategories
.any((cat) => cat.id == entityToLoad.id);
} else if (entityTypeToLoad == EntityType.source &&
entityToLoad is Source) {
isCurrentlyFollowing = currentAccountState
.preferences!
.followedSources
.any((src) => src.id == entityToLoad.id);
}
}

emit(
state.copyWith(
status: EntityDetailsStatus.success,
entityType: entityTypeToLoad,
entity: entityToLoad,
isFollowing: isCurrentlyFollowing,
headlines: headlinesResponse.items,
headlinesStatus: EntityHeadlinesStatus.success,
hasMoreHeadlines: headlinesResponse.hasMore,
headlinesCursor: headlinesResponse.cursor,
clearErrorMessage: true,
),
);
} on HtHttpException catch (e) {
emit(
state.copyWith(
status: EntityDetailsStatus.failure,
errorMessage: e.message,
entityType: entityTypeToLoad, // Keep type if known
),
);
} catch (e) {
emit(
state.copyWith(
status: EntityDetailsStatus.failure,
errorMessage: 'An unexpected error occurred: $e',
entityType: entityTypeToLoad, // Keep type if known
),
);
}
}

Future<void> _onEntityDetailsToggleFollowRequested(
EntityDetailsToggleFollowRequested event,
Emitter<EntityDetailsState> emit,
) async {
if (state.entity == null || state.entityType == null) {
// Cannot toggle follow if no entity is loaded
emit(
state.copyWith(
errorMessage: 'No entity loaded to follow/unfollow.',
clearErrorMessage: false, // Keep existing error if any, or set new
),
);
return;
}

// Optimistic update of UI can be handled by listening to AccountBloc state changes
// which will trigger _onEntityDetailsUserPreferencesChanged.

if (state.entityType == EntityType.category && state.entity is Category) {
_accountBloc.add(
AccountFollowCategoryToggled(category: state.entity as Category),
);
} else if (state.entityType == EntityType.source &&
state.entity is Source) {
_accountBloc.add(
AccountFollowSourceToggled(source: state.entity as Source),
);
} else {
// Should not happen if entity and entityType are consistent
emit(
state.copyWith(
errorMessage: 'Cannot determine entity type to follow/unfollow.',
clearErrorMessage: false,
),
);
}
// Note: We don't emit a new state here for `isFollowing` directly.
// The change will propagate from AccountBloc -> _accountBlocSubscription
// -> _EntityDetailsUserPreferencesChanged -> update state.isFollowing.
// This keeps AccountBloc as the source of truth for preferences.
}

Future<void> _onEntityDetailsLoadMoreHeadlinesRequested(
EntityDetailsLoadMoreHeadlinesRequested event,
Emitter<EntityDetailsState> emit,
) async {
if (!state.hasMoreHeadlines ||
state.headlinesStatus == EntityHeadlinesStatus.loadingMore) {
return;
}
if (state.entity == null || state.entityType == null) return;

emit(state.copyWith(headlinesStatus: EntityHeadlinesStatus.loadingMore));

try {
final queryParams = <String, dynamic>{};
if (state.entityType == EntityType.category) {
queryParams['categories'] = (state.entity as Category).id;
} else if (state.entityType == EntityType.source) {
queryParams['sources'] = (state.entity as Source).id;
} else {
// Should not happen
emit(
state.copyWith(
headlinesStatus: EntityHeadlinesStatus.failure,
errorMessage: 'Cannot load more headlines: Unknown entity type.',
),
);
return;
}

final headlinesResponse = await _headlinesRepository.readAllByQuery(
queryParams,
limit: _headlinesLimit,
startAfterId: state.headlinesCursor,
);

emit(
state.copyWith(
headlines: List.of(state.headlines)..addAll(headlinesResponse.items),
headlinesStatus: EntityHeadlinesStatus.success,
hasMoreHeadlines: headlinesResponse.hasMore,
headlinesCursor: headlinesResponse.cursor,
clearHeadlinesCursor: !headlinesResponse.hasMore, // Clear if no more
),
);
} on HtHttpException catch (e) {
emit(
state.copyWith(
headlinesStatus: EntityHeadlinesStatus.failure,
errorMessage: e.message,
),
);
} catch (e) {
emit(
state.copyWith(
headlinesStatus: EntityHeadlinesStatus.failure,
errorMessage: 'An unexpected error occurred: $e',
),
);
}
}

void _onEntityDetailsUserPreferencesChanged(
_EntityDetailsUserPreferencesChanged event,
Emitter<EntityDetailsState> emit,
) {
if (state.entity == null || state.entityType == null) return;

var isCurrentlyFollowing = false;
final preferences = event.preferences;

if (state.entityType == EntityType.category && state.entity is Category) {
final currentCategory = state.entity as Category;
isCurrentlyFollowing = preferences.followedCategories.any(
(cat) => cat.id == currentCategory.id,
);
} else if (state.entityType == EntityType.source &&
state.entity is Source) {
final currentSource = state.entity as Source;
isCurrentlyFollowing = preferences.followedSources.any(
(src) => src.id == currentSource.id,
);
}

if (state.isFollowing != isCurrentlyFollowing) {
emit(state.copyWith(isFollowing: isCurrentlyFollowing));
}
}

@override
Future<void> close() {
_accountBlocSubscription.cancel();
return super.close();
}
}
Loading
Loading