Use status codes to decide activity search behavior (#3718)

* Add status codes to activity search

* Fix strange request error handling

* Make search react appropriately to 400+ status codes

* Small readability fixes

* Use enum for activity search status

* Fix switched logic in hideCards
This commit is contained in:
Kelrap 2025-08-14 12:28:02 -04:00 committed by GitHub
parent a43c5ffac5
commit 7ca87a9179
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 168 additions and 90 deletions

View file

@ -1,5 +1,7 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:get_storage/get_storage.dart';
import 'package:http/http.dart';
@ -14,22 +16,27 @@ class ActivitySearchRepo {
static final GetStorage _activityPlanStorage =
GetStorage('activity_plan_search_storage');
static void set(ActivityPlanRequest request, ActivityPlanResponse response) {
_activityPlanStorage.write(request.storageKey, response.toJson());
static void set(
String storageKey,
ActivityPlanResponseWrapper wrappedResponse,
) {
_activityPlanStorage.write(storageKey, wrappedResponse.toJson());
}
static Future<ActivityPlanResponse> get(ActivityPlanRequest request) async {
final cachedJson = _activityPlanStorage.read(request.storageKey);
if (cachedJson != null &&
(cachedJson['activity_plans'] as List).isNotEmpty) {
ActivityPlanResponse? cached;
static Future<ActivityPlanResponseWrapper> get(
ActivityPlanRequest request,
) async {
final storageKey = "${request.storageKey}_wrapper";
final cachedJson = _activityPlanStorage.read(storageKey);
if (cachedJson != null) {
ActivityPlanResponseWrapper? cached;
try {
cached = ActivityPlanResponse.fromJson(cachedJson);
cached = ActivityPlanResponseWrapper.fromJson(cachedJson);
} catch (e) {
_activityPlanStorage.remove(request.storageKey);
_activityPlanStorage.remove(storageKey);
}
if (cached != null) {
if (cached is ActivityPlanResponseWrapper) {
return cached;
}
}
@ -39,16 +46,57 @@ class ActivitySearchRepo {
accessToken: MatrixState.pangeaController.userController.accessToken,
);
final Response res = await req.post(
url: PApiUrls.activityPlanSearch,
body: request.toJson(),
Response? res;
try {
res = await req.post(
url: PApiUrls.activityPlanSearch,
body: request.toJson(),
);
} catch (err) {
debugPrint("err: $err, err is http response: ${err is Response}");
if (err is Response) {
return ActivityPlanResponseWrapper(
response: ActivityPlanResponse(activityPlans: []),
statusCode: err.statusCode,
);
}
}
final decodedBody = jsonDecode(utf8.decode(res!.bodyBytes));
final response = ActivityPlanResponse.fromJson(decodedBody);
final wrappedResponse = ActivityPlanResponseWrapper(
response: response,
statusCode: res.statusCode,
);
final decodedBody = jsonDecode(utf8.decode(res.bodyBytes));
final response = ActivityPlanResponse.fromJson(decodedBody);
if (res.statusCode == 200) {
set(storageKey, wrappedResponse);
}
set(request, response);
return response;
return wrappedResponse;
}
}
class ActivityPlanResponseWrapper {
final ActivityPlanResponse response;
final int statusCode;
ActivityPlanResponseWrapper({
required this.response,
required this.statusCode,
});
factory ActivityPlanResponseWrapper.fromJson(Map<String, dynamic> json) {
return ActivityPlanResponseWrapper(
response: json['activity_plan_response'].fromJson,
statusCode: json['activity_response_status'],
);
}
Map<String, dynamic> toJson() {
return {
'activity_plan_response': response.toJson(),
'activity_response_status': statusCode,
};
}
}

View file

@ -0,0 +1,52 @@
import 'package:fluffychat/l10n/l10n.dart';
/// 200: All activities successfully retrieved
/// 202: Waiting for activities to load
/// 504: Timeout
/// Other: Error
enum ActivitySearchEnum {
complete,
waiting,
timeout,
error,
}
extension ActivitySearchExtension on ActivitySearchEnum {
ActivitySearchEnum fromCode(int statusCode) {
switch (statusCode) {
case 200:
return ActivitySearchEnum.complete;
case 202:
return ActivitySearchEnum.waiting;
case 504:
return ActivitySearchEnum.timeout;
default:
return ActivitySearchEnum.error;
}
}
bool get hideCards {
switch (this) {
case ActivitySearchEnum.complete:
case ActivitySearchEnum.waiting:
return false;
case ActivitySearchEnum.timeout:
case ActivitySearchEnum.error:
return true;
}
}
String message(L10n l10n) {
switch (this) {
case ActivitySearchEnum.waiting:
l10n.activitySuggestionTimeoutMessage;
case ActivitySearchEnum.timeout:
return l10n.generatingNewActivities;
case ActivitySearchEnum.error:
return l10n.errorFetchingActivitiesMessage;
default:
return '';
}
return '';
}
}

View file

@ -15,9 +15,9 @@ import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/activity_generator/media_enum.dart';
import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart';
import 'package:fluffychat/pangea/activity_planner/activity_plan_request.dart';
import 'package:fluffychat/pangea/activity_planner/activity_plan_response.dart';
import 'package:fluffychat/pangea/activity_planner/activity_planner_builder.dart';
import 'package:fluffychat/pangea/activity_suggestions/activity_plan_search_repo.dart';
import 'package:fluffychat/pangea/activity_suggestions/activity_search_enum.dart';
import 'package:fluffychat/pangea/activity_suggestions/activity_suggestion_card.dart';
import 'package:fluffychat/pangea/activity_suggestions/activity_suggestion_dialog.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
@ -65,9 +65,8 @@ class ActivitySuggestionsAreaState extends State<ActivitySuggestionsArea> {
// _loading is true when _setActivityItems is currently requesting activities
bool _loading = true;
// _timeout is true if 1+ round of _setActivityItems
// has occurred and no activities retrieved
bool _timeout = false;
ActivitySearchEnum _status = ActivitySearchEnum.waiting;
bool get _isColumnMode => FluffyThemes.isColumnMode(context);
final List<ActivityPlanModel> _activityItems = [];
@ -100,7 +99,7 @@ class ActivitySuggestionsAreaState extends State<ActivitySuggestionsArea> {
if (retries > 3) {
if (mounted) {
setState(() {
_timeout = true;
_status = ActivitySearchEnum.timeout;
_loading = false;
});
}
@ -108,54 +107,46 @@ class ActivitySuggestionsAreaState extends State<ActivitySuggestionsArea> {
}
try {
if (retries == 0) {
if (retries == 0 && mounted) {
setState(() {
_activityItems.clear();
_loading = true;
_timeout = false;
_status = ActivitySearchEnum.waiting;
});
}
final resp = await ActivitySearchRepo.get(_request).timeout(
const Duration(seconds: 5),
onTimeout: () {
if (mounted) {
setState(() {
_timeout = true;
});
}
if (mounted) setState(() => _status = ActivitySearchEnum.timeout);
Future.delayed(const Duration(seconds: 5), () {
if (mounted) _setActivityItems(retries: retries + 1);
});
return Future<ActivityPlanResponse>.error(
return Future<ActivityPlanResponseWrapper>.error(
TimeoutException(
L10n.of(context).activitySuggestionTimeoutMessage,
),
);
},
);
_activityItems.addAll(resp.activityPlans);
_activityItems.addAll(resp.response.activityPlans);
_status = _status.fromCode(resp.statusCode);
if (_status != ActivitySearchEnum.error) {
if (_activityItems.isEmpty) {
if (mounted) setState(() => _status = ActivitySearchEnum.timeout);
// If activities are not successfully retrieved, try again
if (_activityItems.isEmpty) {
if (mounted) {
setState(() {
_timeout = true;
Future.delayed(const Duration(seconds: 5), () {
if (mounted) _setActivityItems(retries: retries + 1);
});
} else {
if (mounted) setState(() => _loading = false);
}
Future.delayed(const Duration(seconds: 5), () {
if (mounted) _setActivityItems(retries: retries + 1);
});
throw TimeoutException(
L10n.of(context).activitySuggestionTimeoutMessage,
);
}
} on TimeoutException {
rethrow;
} catch (e, s) {
if (e is! TimeoutException) rethrow;
ErrorHandler.logError(
e: e,
s: s,
@ -165,16 +156,6 @@ class ActivitySuggestionsAreaState extends State<ActivitySuggestionsArea> {
},
level: SentryLevel.warning,
);
} finally {
// If activities are successfully retrieved, set timeout and loading to false
if (mounted && _activityItems.isNotEmpty) {
setState(
() {
_loading = false;
_timeout = false;
},
);
}
}
}
@ -186,7 +167,8 @@ class ActivitySuggestionsAreaState extends State<ActivitySuggestionsArea> {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final List<Widget> cards = _loading
// Show all loaded activities, or a loading view if there are none
final List<Widget> cards = _activityItems.isEmpty
? List.generate(5, (i) {
return Shimmer.fromColors(
baseColor: theme.colorScheme.primary.withAlpha(20),
@ -243,7 +225,7 @@ class ActivitySuggestionsAreaState extends State<ActivitySuggestionsArea> {
children: [
AnimatedSize(
duration: FluffyThemes.animationDuration,
child: _timeout
child: _status.hideCards
? Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
@ -253,12 +235,13 @@ class ActivitySuggestionsAreaState extends State<ActivitySuggestionsArea> {
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 300),
child: Text(
L10n.of(context).generatingNewActivities,
_status.message(L10n.of(context)),
textAlign: TextAlign.center,
),
),
if (_loading) const CircularProgressIndicator(),
if (!_loading)
if (_loading && _status == ActivitySearchEnum.timeout)
const CircularProgressIndicator(),
if (!_loading && _status == ActivitySearchEnum.timeout)
ElevatedButton(
onPressed: _setActivityItems,
style: ElevatedButton.styleFrom(
@ -307,8 +290,7 @@ class ActivitySuggestionsAreaState extends State<ActivitySuggestionsArea> {
Padding(
padding: const EdgeInsetsGeometry.all(16.0),
child: ErrorIndicator(
message: L10n.of(context)
.activitySuggestionTimeoutMessage,
message: _status.message(L10n.of(context)),
),
),
if (cards.length < 5 && _loading)

View file

@ -78,6 +78,26 @@ class Requests {
Uri _uriBuilder(url) =>
baseUrl != null ? Uri.parse(baseUrl! + url) : Uri.parse(url);
void addBreadcrumb(
http.Response response, {
Map<dynamic, dynamic>? body,
String? objectId,
}) {
debugPrint("Error - code: ${response.statusCode}");
debugPrint("api: ${response.request?.url}");
debugPrint("request body: ${body ?? objectId}");
Sentry.addBreadcrumb(
Breadcrumb.http(
url: response.request?.url ?? Uri(path: "not available"),
method: response.request?.method ?? "not available",
statusCode: response.statusCode,
),
);
Sentry.addBreadcrumb(
Breadcrumb(data: {"body": body, "objectId": objectId}),
);
}
void handleError(
http.Response response, {
Map<dynamic, dynamic>? body,
@ -86,33 +106,9 @@ class Requests {
//PTODO - handle 401 error - unauthorized call
//kick them back to login?
addBreadcrumb() {
debugPrint("Error - code: ${response.statusCode}");
debugPrint("api: ${response.request?.url}");
debugPrint("request body: ${body ?? objectId}");
Sentry.addBreadcrumb(
Breadcrumb.http(
url: response.request?.url ?? Uri(path: "not available"),
method: response.request?.method ?? "not available",
statusCode: response.statusCode,
),
);
Sentry.addBreadcrumb(
Breadcrumb(data: {"body": body, "objectId": objectId}),
);
}
switch (response.statusCode) {
case 200:
case 201:
break;
case 502:
case 504:
addBreadcrumb();
throw response;
default:
addBreadcrumb();
throw response;
if (response.statusCode >= 400) {
addBreadcrumb(response, body: body, objectId: objectId);
throw response;
}
}