Skip to content
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

1214: Add pois to favorites #1461

Merged
merged 11 commits into from
Jun 17, 2024
13 changes: 12 additions & 1 deletion frontend/assets/l10n/app_de.json
Original file line number Diff line number Diff line change
Expand Up @@ -176,13 +176,24 @@
"store": {
"acceptingStore": "Akzeptanzstelle",
"acceptingStoreNotFound": "Akzeptanzstelle nicht gefunden.",
"acceptingStoreNotAvailable": "Diese Akzeptanzstelle ist nicht mehr verfügbar.",
"address": "Adresse",
"email": "E-Mail",
"loadingDataFailed": "Fehler beim Laden der Daten.",
"noDescriptionAvailable": "Keine Beschreibung verfügbar",
"phone": "Telefon",
"showOnMap": "Auf Karte zeigen",
"unknownCategory": "Unbekannte Kategorie",
"website": "Website"
"website": "Website",
"removeButtonText": "Entfernen",
"removeDescription": "Möchten Sie sie aus Ihren Favoriten entfernen?"
},
"favorites": {
"title": "Favoriten",
"noFavoritesFound": "Sie haben noch keine Akzeptanzstellen zu Ihren Favoriten hinzugefügt.",
"favoriteHasBeenAdded": "Die Akzeptanzstelle wurde zu den Favoriten hinzugefügt.",
"favoriteHasBeenRemoved": "Die Akzeptanzstelle wurde aus den Favoriten entfernt.",
"loadingFailed": "Fehler beim Laden der Favoriten.",
"updateFailed": "Fehler bei der Aktualisierung der Favoriten."
}
}
15 changes: 13 additions & 2 deletions frontend/assets/l10n/app_en.json
Original file line number Diff line number Diff line change
Expand Up @@ -174,15 +174,26 @@
"title": "Search"
},
"store": {
"acceptingStore": "Acceptance points",
"acceptingStore": "Acceptance point",
"acceptingStoreNotFound": "Acceptance point not found.",
"acceptingStoreNotAvailable": "This acceptance point is no longer available.",
"address": "Address",
"email": "E-Mail",
"loadingDataFailed": "Error loading the data.",
"noDescriptionAvailable": "No description available",
"phone": "Phone",
"showOnMap": "Show on map",
"unknownCategory": "Unknown category",
"website": "Website"
"website": "Website",
"removeButtonText": "Remove",
"removeDescription": "Would you like to remove it from your favorites?"
},
"favorites": {
"title": "Favorites",
"noFavoritesFound": "You have not added any acceptance points to your favorites yet.",
seluianova marked this conversation as resolved.
Show resolved Hide resolved
"favoriteHasBeenAdded": "The acceptance point has been added to the favorites.",
seluianova marked this conversation as resolved.
Show resolved Hide resolved
"favoriteHasBeenRemoved": "The acceptance point has been removed from the favorites.",
"loadingFailed": "Failed to load favorites.",
"updateFailed": "Failed to update favorites."
}
}
8 changes: 6 additions & 2 deletions frontend/lib/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'package:ehrenamtskarte/build_config/build_config.dart';
import 'package:ehrenamtskarte/configuration/configuration.dart';
import 'package:ehrenamtskarte/configuration/definitions.dart';
import 'package:ehrenamtskarte/configuration/settings_model.dart';
import 'package:ehrenamtskarte/favorites/favorites_model.dart';
import 'package:ehrenamtskarte/graphql/configured_graphql_provider.dart';
import 'package:ehrenamtskarte/identification/user_code_model.dart';
import 'package:ehrenamtskarte/intro_slides/intro_screen.dart';
Expand Down Expand Up @@ -109,8 +110,11 @@ class App extends StatelessWidget {
projectId: projectId,
showDevSettings: kDebugMode,
child: ConfiguredGraphQlProvider(
child: ChangeNotifierProvider(
create: (context) => UserCodeModel()..initialize(),
child: MultiProvider(
providers: [
ChangeNotifierProvider<UserCodeModel>(create: (_) => UserCodeModel()..initialize()),
ChangeNotifierProvider<FavoritesModel>(create: (_) => FavoritesModel()..initialize())
],
child: MaterialApp.router(
theme: lightTheme,
darkTheme: darkTheme,
Expand Down
18 changes: 18 additions & 0 deletions frontend/lib/favorites/favorite_store.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import 'package:ehrenamtskarte/graphql/graphql_api.graphql.dart';

class FavoriteStore {
final int storeId;
final String storeName;
final int categoryId;

AcceptingStoreById$Query$PhysicalStore? physicalStore;

FavoriteStore(this.storeId, this.storeName, this.categoryId);

FavoriteStore.fromJson(Map<String, dynamic> json)
: storeId = json['storeId'] as int,
storeName = json['storeName'] as String,
categoryId = json['categoryId'] as int;

Map<String, dynamic> toJson() => {'storeId': storeId, 'storeName': storeName, 'categoryId': categoryId};
}
204 changes: 204 additions & 0 deletions frontend/lib/favorites/favorites_loader.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import 'dart:math';

import 'package:flutter/material.dart';
import 'package:graphql_flutter/graphql_flutter.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';

import 'package:ehrenamtskarte/graphql/graphql_api.graphql.dart';
import 'package:ehrenamtskarte/home/home_page.dart';
import 'package:ehrenamtskarte/favorites/favorites_model.dart';
import 'package:ehrenamtskarte/favorites/favorite_store.dart';
import 'package:ehrenamtskarte/map/preview/models.dart';
import 'package:ehrenamtskarte/store_widgets/accepting_store_summary.dart';
import 'package:ehrenamtskarte/store_widgets/removed_store_summary.dart';
import 'package:ehrenamtskarte/configuration/configuration.dart';
import 'package:ehrenamtskarte/l10n/translations.g.dart';
import 'package:provider/provider.dart';

class FavoritesLoader extends StatefulWidget {
const FavoritesLoader({super.key});

@override
State<StatefulWidget> createState() => FavoritesLoaderState();
}

class FavoritesLoaderState extends State<FavoritesLoader> {
static const _pageSize = 20;

GraphQLClient? _client;

late FavoritesModel _favoritesModel;

final PagingController<int, FavoriteStore> _pagingController = PagingController(firstPageKey: 0);

@override
void initState() {
super.initState();
_pagingController.addPageRequestListener(_fetchPage);
_favoritesModel = Provider.of<FavoritesModel>(context, listen: false);
_favoritesModel.addListener(() {
_pagingController.refresh();
});
}

@override
void didChangeDependencies() {
super.didChangeDependencies();
final client = GraphQLProvider.of(context).value;
if (client != _client) {
_client = client;
}
}

@override
void didUpdateWidget(FavoritesLoader oldWidget) {
super.didUpdateWidget(oldWidget);
_pagingController.refresh();
}

Future<void> _fetchPage(int pageKey) async {
try {
final favoritesModel = _favoritesModel;
if (!favoritesModel.isInitialized) {
throw Exception('Failed to load favorites');
}

final favorites = favoritesModel.getFavoriteIds();

if (favorites.isEmpty || pageKey >= favorites.length) {
_pagingController.appendLastPage(List.empty());
return;
}

final fetchIds = favorites.getRange(pageKey, min(pageKey + _pageSize, favorites.length)).toList();

final newItems = await Future.wait(fetchIds
.map((id) async => favoritesModel.getFavoriteStore(id)..physicalStore = await _fetchPhysicalStore(id)));

final isLastPage = newItems.length < _pageSize;
if (isLastPage) {
_pagingController.appendLastPage(newItems);
} else {
final nextPageKey = pageKey + newItems.length;
_pagingController.appendPage(newItems, nextPageKey);
}
} catch (error) {
_pagingController.error = error;
}
}

Future<AcceptingStoreById$Query$PhysicalStore?> _fetchPhysicalStore(int storeId) async {
final projectId = Configuration.of(context).projectId;

final query = AcceptingStoreByIdQuery(variables: AcceptingStoreByIdArguments(project: projectId, ids: [storeId]));

final client = _client;
if (client == null) {
throw Exception('GraphQL client is not yet initialized!');
}

final result = await client.query(QueryOptions(document: query.document, variables: query.getVariablesMap()));
final exception = result.exception;
if (result.hasException && exception != null) {
throw exception;
}

final data = result.data;
if (data == null) {
throw Exception('Fetched data is null');
}
return query.parse(data).physicalStoresByIdInProject.firstOrNull;
}

@override
Widget build(BuildContext context) {
return PagedSliverList<int, FavoriteStore>.separated(
pagingController: _pagingController,
builderDelegate: PagedChildBuilderDelegate<FavoriteStore>(
itemBuilder: _buildItem,
noItemsFoundIndicatorBuilder: _buildNoItemsFoundIndicator,
firstPageErrorIndicatorBuilder: _buildErrorWithRetry,
newPageErrorIndicatorBuilder: _buildErrorWithRetry,
firstPageProgressIndicatorBuilder: _buildProgressIndicator,
newPageProgressIndicatorBuilder: _buildProgressIndicator,
),
separatorBuilder: (context, index) => const Divider(height: 0),
);
}

Widget _buildItem(BuildContext context, FavoriteStore item, index) {
final physicalStore = item.physicalStore;
if (physicalStore != null) {
return IntrinsicHeight(
child: AcceptingStoreSummary(
key: ValueKey(physicalStore.id),
store: AcceptingStoreSummaryModel(
physicalStore.id,
physicalStore.store.name,
physicalStore.store.description,
physicalStore.store.category.id,
null,
null,
),
showOnMap: (it) => HomePageData.of(context)?.navigateToMapTab(it),
),
);
} else {
return IntrinsicHeight(
child: RemovedStoreSummary(
storeId: item.storeId,
storeName: item.storeName,
categoryId: item.categoryId,
),
);
}
}

Widget _buildProgressIndicator(BuildContext context) =>
const Center(child: Padding(padding: EdgeInsets.all(5), child: CircularProgressIndicator()));

Widget _buildErrorWithRetry(BuildContext context) {
final t = context.t;
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.warning, size: 60, color: Colors.orange),
Text(t.favorites.loadingFailed),
OutlinedButton(
onPressed: _pagingController.retryLastFailedRequest,
child: Text(t.common.tryAgain),
)
],
),
);
}

Widget _buildNoItemsFoundIndicator(BuildContext context) {
final t = context.t;
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.search_off, size: 60, color: Theme.of(context).disabledColor),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
t.favorites.noFavoritesFound,
textAlign: TextAlign.center,
),
),
],
),
);
}

@override
void dispose() {
_favoritesModel.removeListener(() {
_pagingController.refresh();
});
_pagingController.dispose();
super.dispose();
}
}
65 changes: 65 additions & 0 deletions frontend/lib/favorites/favorites_model.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import 'dart:developer';

import 'package:flutter/cupertino.dart';

import 'package:ehrenamtskarte/favorites/favorites_storage.dart';
import 'package:ehrenamtskarte/favorites/favorite_store.dart';

class FavoritesModel extends ChangeNotifier {
List<FavoriteStore> _favoriteStores = [];
bool _isInitialized = false;

Future<void> initialize() async {
if (_isInitialized) {
return;
}
try {
_favoriteStores = await FavoritesStorage().readFavorites();
_isInitialized = true;
} catch (error) {
log('Failed to load favorites from secure storage', error: error);
} finally {
notifyListeners();
}
}

bool get isInitialized {
return _isInitialized;
}

List<FavoriteStore> get favoriteStores {
return _favoriteStores;
}

List<int> getFavoriteIds() {
return _favoriteStores.map((item) => item.storeId).toList();
}

FavoriteStore getFavoriteStore(int storeId) {
return _favoriteStores.where((item) => item.storeId == storeId).first;
}

bool isFavorite(int storeId) {
return getFavoriteIds().contains(storeId);
}

Future<void> saveFavorite(FavoriteStore newFavoriteStore) async {
if (isFavorite(newFavoriteStore.storeId)) {
return;
}
final favoriteStores = [..._favoriteStores, newFavoriteStore];
await FavoritesStorage().writeFavorites(favoriteStores);
_favoriteStores = favoriteStores;
notifyListeners();
}

Future<void> removeFavorite(int storeId) async {
if (!isFavorite(storeId)) {
return;
}
final favoriteStores = [..._favoriteStores]..removeWhere((item) => item.storeId == storeId);
await FavoritesStorage().writeFavorites(favoriteStores);
_favoriteStores = favoriteStores;
notifyListeners();
}
}
30 changes: 30 additions & 0 deletions frontend/lib/favorites/favorites_page.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import 'package:ehrenamtskarte/favorites/favorites_loader.dart';
import 'package:ehrenamtskarte/l10n/translations.g.dart';
import 'package:flutter/material.dart';

class FavoritesPage extends StatefulWidget {
const FavoritesPage({super.key});

@override
State<StatefulWidget> createState() => _FavoritesPageState();
}

class _FavoritesPageState extends State<FavoritesPage> {
@override
Widget build(BuildContext context) {
final t = context.t;

return Stack(
children: [
CustomScrollView(
slivers: [
SliverAppBar(
title: Text(t.favorites.title),
),
FavoritesLoader()
],
),
],
);
}
}
Loading