improve Firebase integration across Crashlytics, Analytics, and Performance
- Fix cold-start privacy bug: apply persisted collection preference on launch - Disable Firebase collection in debug builds to avoid polluting prod data - Add Crashlytics custom keys (is_logged_in, channel), user identifier, and WebSocket reconnection breadcrumbs for actionable crash reports - Record non-fatal errors for server failures, WebSocket errors, and global asset fetch failures (skip timeouts/network errors to avoid noise) - Update is_logged_in key and user identifier on login/logout transitions - Log auth reconnection exhaustion before forced logout - Add FirebaseAnalyticsObserver with named routes for automatic screen tracking - Add static routeName constants to avoid duplicated string literals - Add Firebase Performance Dio interceptor for automatic HTTP request tracing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f07fe621bc
commit
6ede000c66
21 changed files with 191 additions and 41 deletions
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
|
||||||
|
|
||||||
/// Common type aliases to reduce repetition
|
/// Common type aliases to reduce repetition
|
||||||
typedef JsonMap = Map<String, dynamic>;
|
typedef JsonMap = Map<String, dynamic>;
|
||||||
|
|
@ -157,29 +158,35 @@ abstract class BaseApiClient {
|
||||||
|
|
||||||
/// Converts DioException to appropriate ApiException subtype
|
/// Converts DioException to appropriate ApiException subtype
|
||||||
ApiException _handleError(DioException error) {
|
ApiException _handleError(DioException error) {
|
||||||
switch (error.type) {
|
final exception = switch (error.type) {
|
||||||
case DioExceptionType.connectionTimeout:
|
DioExceptionType.connectionTimeout ||
|
||||||
case DioExceptionType.sendTimeout:
|
DioExceptionType.sendTimeout ||
|
||||||
case DioExceptionType.receiveTimeout:
|
DioExceptionType.receiveTimeout =>
|
||||||
return TimeoutException(_getTimeoutMessage(error.type));
|
TimeoutException(_getTimeoutMessage(error.type)),
|
||||||
|
DioExceptionType.connectionError => NetworkException(
|
||||||
case DioExceptionType.connectionError:
|
|
||||||
return NetworkException(
|
|
||||||
'No internet connection. Please check your network.',
|
'No internet connection. Please check your network.',
|
||||||
|
),
|
||||||
|
DioExceptionType.badResponse => _handleHttpError(error),
|
||||||
|
DioExceptionType.cancel => ApiException('Request was cancelled'),
|
||||||
|
DioExceptionType.badCertificate =>
|
||||||
|
ApiException('Security certificate error'),
|
||||||
|
DioExceptionType.unknown =>
|
||||||
|
ApiException(error.message ?? 'An unexpected error occurred'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Record server errors and unexpected failures to Crashlytics.
|
||||||
|
// Skip timeouts, network errors (normal offline conditions), 401s, and cancellations.
|
||||||
|
if (exception is ServerException ||
|
||||||
|
error.type == DioExceptionType.unknown ||
|
||||||
|
error.type == DioExceptionType.badCertificate) {
|
||||||
|
FirebaseCrashlytics.instance.recordError(
|
||||||
|
exception,
|
||||||
|
error.stackTrace,
|
||||||
|
reason: '${error.requestOptions.method} ${error.requestOptions.uri}',
|
||||||
);
|
);
|
||||||
|
|
||||||
case DioExceptionType.badResponse:
|
|
||||||
return _handleHttpError(error);
|
|
||||||
|
|
||||||
case DioExceptionType.cancel:
|
|
||||||
return ApiException('Request was cancelled');
|
|
||||||
|
|
||||||
case DioExceptionType.badCertificate:
|
|
||||||
return ApiException('Security certificate error');
|
|
||||||
|
|
||||||
case DioExceptionType.unknown:
|
|
||||||
return ApiException(error.message ?? 'An unexpected error occurred');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return exception;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Gets specific timeout message based on timeout type
|
/// Gets specific timeout message based on timeout type
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:frosty/apis/firebase_performance_interceptor.dart';
|
||||||
|
|
||||||
/// Centralized Dio client configuration with interceptors and error handling
|
/// Centralized Dio client configuration with interceptors and error handling
|
||||||
class DioClient {
|
class DioClient {
|
||||||
|
|
@ -55,6 +56,9 @@ class DioClient {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Trace HTTP requests as Firebase Performance metrics
|
||||||
|
dio.interceptors.add(FirebasePerformanceInterceptor());
|
||||||
|
|
||||||
// Add any additional interceptors provided
|
// Add any additional interceptors provided
|
||||||
if (additionalInterceptors != null) {
|
if (additionalInterceptors != null) {
|
||||||
for (final interceptor in additionalInterceptors) {
|
for (final interceptor in additionalInterceptors) {
|
||||||
|
|
|
||||||
46
lib/apis/firebase_performance_interceptor.dart
Normal file
46
lib/apis/firebase_performance_interceptor.dart
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:firebase_performance/firebase_performance.dart';
|
||||||
|
|
||||||
|
/// Dio interceptor that traces HTTP requests as Firebase Performance metrics.
|
||||||
|
class FirebasePerformanceInterceptor extends Interceptor {
|
||||||
|
static const _metricKey = 'firebase_performance_metric';
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||||
|
final metric = FirebasePerformance.instance.newHttpMetric(
|
||||||
|
options.uri.toString(),
|
||||||
|
_getHttpMethod(options.method),
|
||||||
|
);
|
||||||
|
metric.start();
|
||||||
|
options.extra[_metricKey] = metric;
|
||||||
|
handler.next(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onResponse(Response response, ResponseInterceptorHandler handler) {
|
||||||
|
_stopMetric(response.requestOptions, response.statusCode);
|
||||||
|
handler.next(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||||
|
_stopMetric(err.requestOptions, err.response?.statusCode);
|
||||||
|
handler.next(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _stopMetric(RequestOptions options, int? statusCode) {
|
||||||
|
final metric = options.extra.remove(_metricKey) as HttpMetric?;
|
||||||
|
if (metric == null) return;
|
||||||
|
if (statusCode != null) metric.httpResponseCode = statusCode;
|
||||||
|
metric.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
static HttpMethod _getHttpMethod(String method) => switch (method.toUpperCase()) {
|
||||||
|
'GET' => HttpMethod.Get,
|
||||||
|
'POST' => HttpMethod.Post,
|
||||||
|
'PUT' => HttpMethod.Put,
|
||||||
|
'DELETE' => HttpMethod.Delete,
|
||||||
|
'PATCH' => HttpMethod.Patch,
|
||||||
|
_ => HttpMethod.Get,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -3,8 +3,10 @@ import 'dart:convert';
|
||||||
|
|
||||||
import 'package:advanced_in_app_review/advanced_in_app_review.dart';
|
import 'package:advanced_in_app_review/advanced_in_app_review.dart';
|
||||||
import 'package:app_links/app_links.dart';
|
import 'package:app_links/app_links.dart';
|
||||||
|
import 'package:firebase_analytics/firebase_analytics.dart';
|
||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
|
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
|
||||||
|
import 'package:firebase_performance/firebase_performance.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_mobx/flutter_mobx.dart';
|
import 'package:flutter_mobx/flutter_mobx.dart';
|
||||||
|
|
@ -77,6 +79,19 @@ void main() async {
|
||||||
// Initialize a settings store from the settings JSON string.
|
// Initialize a settings store from the settings JSON string.
|
||||||
final settingsStore = SettingsStore.fromJson(jsonDecode(userSettings));
|
final settingsStore = SettingsStore.fromJson(jsonDecode(userSettings));
|
||||||
|
|
||||||
|
// Disable Firebase collection in debug builds to avoid polluting production data.
|
||||||
|
// In release builds, respect the user's preference.
|
||||||
|
if (kDebugMode) {
|
||||||
|
FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(false);
|
||||||
|
FirebaseAnalytics.instance.setAnalyticsCollectionEnabled(false);
|
||||||
|
FirebasePerformance.instance.setPerformanceCollectionEnabled(false);
|
||||||
|
} else {
|
||||||
|
final shareEnabled = settingsStore.shareCrashLogsAndAnalytics;
|
||||||
|
FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(shareEnabled);
|
||||||
|
FirebaseAnalytics.instance.setAnalyticsCollectionEnabled(shareEnabled);
|
||||||
|
FirebasePerformance.instance.setPerformanceCollectionEnabled(shareEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
// Create a MobX reaction that will save the settings on disk every time they are changed.
|
// Create a MobX reaction that will save the settings on disk every time they are changed.
|
||||||
autorun((_) => prefs.setString('settings', jsonEncode(settingsStore)));
|
autorun((_) => prefs.setString('settings', jsonEncode(settingsStore)));
|
||||||
|
|
||||||
|
|
@ -108,6 +123,10 @@ void main() async {
|
||||||
dioClient.interceptors.add(UnauthorizedInterceptor(authStore));
|
dioClient.interceptors.add(UnauthorizedInterceptor(authStore));
|
||||||
|
|
||||||
await authStore.init();
|
await authStore.init();
|
||||||
|
FirebaseCrashlytics.instance.setCustomKey('is_logged_in', authStore.isLoggedIn);
|
||||||
|
if (authStore.isLoggedIn && authStore.user.details != null) {
|
||||||
|
FirebaseCrashlytics.instance.setUserIdentifier(authStore.user.details!.id);
|
||||||
|
}
|
||||||
|
|
||||||
runApp(
|
runApp(
|
||||||
MultiProvider(
|
MultiProvider(
|
||||||
|
|
@ -173,6 +192,11 @@ class _MyAppState extends State<MyApp> {
|
||||||
: settingsStore.themeType == ThemeType.light
|
: settingsStore.themeType == ThemeType.light
|
||||||
? ThemeMode.light
|
? ThemeMode.light
|
||||||
: ThemeMode.dark,
|
: ThemeMode.dark,
|
||||||
|
navigatorObservers: [
|
||||||
|
FirebaseAnalyticsObserver(
|
||||||
|
analytics: FirebaseAnalytics.instance,
|
||||||
|
),
|
||||||
|
],
|
||||||
home: widget.firstRun ? const OnboardingIntro() : const Home(),
|
home: widget.firstRun ? const OnboardingIntro() : const Home(),
|
||||||
navigatorKey: navigatorKey,
|
navigatorKey: navigatorKey,
|
||||||
);
|
);
|
||||||
|
|
@ -227,6 +251,7 @@ class _MyAppState extends State<MyApp> {
|
||||||
final user = await twitchApi.getUser(userLogin: channelName);
|
final user = await twitchApi.getUser(userLogin: channelName);
|
||||||
|
|
||||||
final route = MaterialPageRoute(
|
final route = MaterialPageRoute(
|
||||||
|
settings: const RouteSettings(name: VideoChat.routeName),
|
||||||
builder: (context) => VideoChat(
|
builder: (context) => VideoChat(
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
userName: user.displayName,
|
userName: user.displayName,
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ import 'package:simple_pip_mode/pip_widget.dart';
|
||||||
|
|
||||||
/// Creates a widget that shows the video stream (if live) and chat of the given user.
|
/// Creates a widget that shows the video stream (if live) and chat of the given user.
|
||||||
class VideoChat extends StatefulWidget {
|
class VideoChat extends StatefulWidget {
|
||||||
|
static const routeName = 'VideoChat';
|
||||||
|
|
||||||
final String userId;
|
final String userId;
|
||||||
final String userName;
|
final String userName;
|
||||||
final String userLogin;
|
final String userLogin;
|
||||||
|
|
|
||||||
|
|
@ -416,6 +416,7 @@ class _ChatDetailsState extends State<ChatDetails> {
|
||||||
onTap: () => Navigator.push(
|
onTap: () => Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
|
settings: const RouteSettings(name: Settings.routeName),
|
||||||
builder: (context) =>
|
builder: (context) =>
|
||||||
Settings(settingsStore: widget.chatStore.settings),
|
Settings(settingsStore: widget.chatStore.settings),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:frosty/apis/twitch_api.dart';
|
import 'package:frosty/apis/twitch_api.dart';
|
||||||
|
|
@ -862,7 +863,14 @@ abstract class ChatStoreBase with Store {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error) => debugPrint('7TV events error: ${error.toString()}'),
|
onError: (error) {
|
||||||
|
debugPrint('7TV events error: ${error.toString()}');
|
||||||
|
FirebaseCrashlytics.instance.recordError(
|
||||||
|
error,
|
||||||
|
StackTrace.current,
|
||||||
|
reason: '7TV WebSocket error, channel=$channelName',
|
||||||
|
);
|
||||||
|
},
|
||||||
onDone: () {
|
onDone: () {
|
||||||
_clearPendingDelayedCallbacks(clearChat: false);
|
_clearPendingDelayedCallbacks(clearChat: false);
|
||||||
debugPrint('7TV events done');
|
debugPrint('7TV events done');
|
||||||
|
|
@ -874,6 +882,8 @@ abstract class ChatStoreBase with Store {
|
||||||
|
|
||||||
@action
|
@action
|
||||||
Future<void> connectToChat({bool isReconnect = false}) async {
|
Future<void> connectToChat({bool isReconnect = false}) async {
|
||||||
|
FirebaseCrashlytics.instance.setCustomKey('channel', channelName);
|
||||||
|
|
||||||
// Fetch assets first so they're available for all messages
|
// Fetch assets first so they're available for all messages
|
||||||
getAssets().catchError(
|
getAssets().catchError(
|
||||||
(e) => debugPrint('Failed to fetch chat assets: $e'),
|
(e) => debugPrint('Failed to fetch chat assets: $e'),
|
||||||
|
|
@ -907,7 +917,14 @@ abstract class ChatStoreBase with Store {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error) => debugPrint('Chat error: ${error.toString()}'),
|
onError: (error) {
|
||||||
|
debugPrint('Chat error: ${error.toString()}');
|
||||||
|
FirebaseCrashlytics.instance.recordError(
|
||||||
|
error,
|
||||||
|
StackTrace.current,
|
||||||
|
reason: 'IRC WebSocket error, channel=$channelName',
|
||||||
|
);
|
||||||
|
},
|
||||||
onDone: () async {
|
onDone: () async {
|
||||||
_clearPendingDelayedCallbacks(clearSevenTV: false);
|
_clearPendingDelayedCallbacks(clearSevenTV: false);
|
||||||
|
|
||||||
|
|
@ -924,6 +941,9 @@ abstract class ChatStoreBase with Store {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_retries >= _maxRetries) {
|
if (_retries >= _maxRetries) {
|
||||||
|
FirebaseCrashlytics.instance.log(
|
||||||
|
'WebSocket reconnection failed after $_maxRetries attempts, channel=$channelName',
|
||||||
|
);
|
||||||
// Remove the reconnect message before showing final disconnect notice
|
// Remove the reconnect message before showing final disconnect notice
|
||||||
if (_reconnectMessage != null) {
|
if (_reconnectMessage != null) {
|
||||||
final index = _messages.indexOf(_reconnectMessage!);
|
final index = _messages.indexOf(_reconnectMessage!);
|
||||||
|
|
@ -949,6 +969,9 @@ abstract class ChatStoreBase with Store {
|
||||||
|
|
||||||
// Increment the retry count.
|
// Increment the retry count.
|
||||||
_retries++;
|
_retries++;
|
||||||
|
FirebaseCrashlytics.instance.log(
|
||||||
|
'WebSocket reconnecting: attempt $_retries/$_maxRetries, channel=$channelName',
|
||||||
|
);
|
||||||
|
|
||||||
// Start exponential backoff only after first failed attempt (capped at 8s).
|
// Start exponential backoff only after first failed attempt (capped at 8s).
|
||||||
// First retry is immediate to catch brief network hiccups.
|
// First retry is immediate to catch brief network hiccups.
|
||||||
|
|
|
||||||
|
|
@ -202,6 +202,7 @@ class StreamInfoBar extends StatelessWidget {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
|
settings: const RouteSettings(name: CategoryStreams.routeName),
|
||||||
builder: (context) =>
|
builder: (context) =>
|
||||||
CategoryStreams(
|
CategoryStreams(
|
||||||
categoryId:
|
categoryId:
|
||||||
|
|
@ -293,6 +294,7 @@ class StreamInfoBar extends StatelessWidget {
|
||||||
onDoubleTap: () => Navigator.push(
|
onDoubleTap: () => Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
|
settings: const RouteSettings(name: CategoryStreams.routeName),
|
||||||
builder: (context) => CategoryStreams(
|
builder: (context) => CategoryStreams(
|
||||||
categoryId:
|
categoryId:
|
||||||
streamInfo?.gameId ?? '',
|
streamInfo?.gameId ?? '',
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,10 @@ class _HomeState extends State<Home> {
|
||||||
|
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(builder: (context) => const ReleaseNotes()),
|
MaterialPageRoute(
|
||||||
|
settings: const RouteSettings(name: ReleaseNotes.routeName),
|
||||||
|
builder: (context) => const ReleaseNotes(),
|
||||||
|
),
|
||||||
).then((_) => prefs.setString('last_shown_version', currentVersion));
|
).then((_) => prefs.setString('last_shown_version', currentVersion));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -121,6 +124,7 @@ class _HomeState extends State<Home> {
|
||||||
onPressed: () => Navigator.push(
|
onPressed: () => Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
|
settings: const RouteSettings(name: Settings.routeName),
|
||||||
builder: (context) => Settings(
|
builder: (context) => Settings(
|
||||||
settingsStore: context.read<SettingsStore>(),
|
settingsStore: context.read<SettingsStore>(),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ class _SearchResultsChannelsState extends State<SearchResultsChannels> {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
|
settings: const RouteSettings(name: VideoChat.routeName),
|
||||||
builder: (context) => VideoChat(
|
builder: (context) => VideoChat(
|
||||||
userId: channelInfo.broadcasterId,
|
userId: channelInfo.broadcasterId,
|
||||||
userName: channelInfo.broadcasterName,
|
userName: channelInfo.broadcasterName,
|
||||||
|
|
@ -120,6 +121,7 @@ class _SearchResultsChannelsState extends State<SearchResultsChannels> {
|
||||||
onTap: () => Navigator.push(
|
onTap: () => Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
|
settings: const RouteSettings(name: VideoChat.routeName),
|
||||||
builder: (context) => VideoChat(
|
builder: (context) => VideoChat(
|
||||||
userId: channel.id,
|
userId: channel.id,
|
||||||
userName: channel.displayName,
|
userName: channel.displayName,
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@ class LargeStreamCard extends StatelessWidget {
|
||||||
onTap: () => Navigator.push(
|
onTap: () => Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
|
settings: const RouteSettings(name: VideoChat.routeName),
|
||||||
builder: (context) => VideoChat(
|
builder: (context) => VideoChat(
|
||||||
userId: streamInfo.userId,
|
userId: streamInfo.userId,
|
||||||
userName: streamInfo.userName,
|
userName: streamInfo.userName,
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ class OfflineChannelCard extends StatelessWidget {
|
||||||
onTap: () => Navigator.push(
|
onTap: () => Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
|
settings: const RouteSettings(name: VideoChat.routeName),
|
||||||
builder: (context) => VideoChat(
|
builder: (context) => VideoChat(
|
||||||
userId: channelInfo.broadcasterId,
|
userId: channelInfo.broadcasterId,
|
||||||
userName: channelInfo.broadcasterName,
|
userName: channelInfo.broadcasterName,
|
||||||
|
|
|
||||||
|
|
@ -168,6 +168,7 @@ class StreamCard extends StatelessWidget {
|
||||||
? () => Navigator.push(
|
? () => Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
|
settings: const RouteSettings(name: CategoryStreams.routeName),
|
||||||
builder: (context) =>
|
builder: (context) =>
|
||||||
CategoryStreams(categoryId: streamInfo.gameId),
|
CategoryStreams(categoryId: streamInfo.gameId),
|
||||||
),
|
),
|
||||||
|
|
@ -203,6 +204,7 @@ class StreamCard extends StatelessWidget {
|
||||||
onTap: () => Navigator.push(
|
onTap: () => Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
|
settings: const RouteSettings(name: VideoChat.routeName),
|
||||||
builder: (context) => VideoChat(
|
builder: (context) => VideoChat(
|
||||||
userId: streamInfo.userId,
|
userId: streamInfo.userId,
|
||||||
userName: streamInfo.userName,
|
userName: streamInfo.userName,
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ class CategoryCard extends StatelessWidget {
|
||||||
? () => Navigator.push(
|
? () => Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
|
settings: const RouteSettings(name: CategoryStreams.routeName),
|
||||||
builder: (context) => CategoryStreams(categoryId: category.id),
|
builder: (context) => CategoryStreams(categoryId: category.id),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ import 'package:provider/provider.dart';
|
||||||
|
|
||||||
/// A widget that displays a list of streams under the provided [categoryId].
|
/// A widget that displays a list of streams under the provided [categoryId].
|
||||||
class CategoryStreams extends StatefulWidget {
|
class CategoryStreams extends StatefulWidget {
|
||||||
|
static const routeName = 'CategoryStreams';
|
||||||
|
|
||||||
/// The category id, used for fetching the relevant streams in the [ListStore].
|
/// The category id, used for fetching the relevant streams in the [ListStore].
|
||||||
final String categoryId;
|
final String categoryId;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,10 @@ class _OtherSettingsState extends State<OtherSettings> {
|
||||||
title: const Text('Release notes'),
|
title: const Text('Release notes'),
|
||||||
onTap: () => Navigator.of(
|
onTap: () => Navigator.of(
|
||||||
context,
|
context,
|
||||||
).push(MaterialPageRoute(builder: (context) => const ReleaseNotes())),
|
).push(MaterialPageRoute(
|
||||||
|
settings: const RouteSettings(name: ReleaseNotes.routeName),
|
||||||
|
builder: (context) => const ReleaseNotes(),
|
||||||
|
)),
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.delete_outline_rounded),
|
leading: const Icon(Icons.delete_outline_rounded),
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ import 'package:simple_icons/simple_icons.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
class Settings extends StatelessWidget {
|
class Settings extends StatelessWidget {
|
||||||
|
static const routeName = 'Settings';
|
||||||
|
|
||||||
final SettingsStore settingsStore;
|
final SettingsStore settingsStore;
|
||||||
|
|
||||||
const Settings({super.key, required this.settingsStore});
|
const Settings({super.key, required this.settingsStore});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
import 'package:frosty/apis/base_api_client.dart';
|
import 'package:frosty/apis/base_api_client.dart';
|
||||||
|
|
@ -250,6 +251,7 @@ abstract class AuthBase with Store {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FirebaseCrashlytics.instance.setCustomKey('is_logged_in', _isLoggedIn);
|
||||||
_error = null;
|
_error = null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint(e.toString());
|
debugPrint(e.toString());
|
||||||
|
|
@ -277,6 +279,8 @@ abstract class AuthBase with Store {
|
||||||
// Set the login status to logged in.
|
// Set the login status to logged in.
|
||||||
if (user.details != null) {
|
if (user.details != null) {
|
||||||
_isLoggedIn = true;
|
_isLoggedIn = true;
|
||||||
|
FirebaseCrashlytics.instance.setCustomKey('is_logged_in', true);
|
||||||
|
FirebaseCrashlytics.instance.setUserIdentifier(user.details!.id);
|
||||||
_stopReconnectLoop();
|
_stopReconnectLoop();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -307,6 +311,8 @@ abstract class AuthBase with Store {
|
||||||
|
|
||||||
// Set the login status to logged out.
|
// Set the login status to logged out.
|
||||||
_isLoggedIn = false;
|
_isLoggedIn = false;
|
||||||
|
FirebaseCrashlytics.instance.setCustomKey('is_logged_in', false);
|
||||||
|
FirebaseCrashlytics.instance.setUserIdentifier('');
|
||||||
|
|
||||||
debugPrint('Successfully logged out');
|
debugPrint('Successfully logged out');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -321,6 +327,9 @@ abstract class AuthBase with Store {
|
||||||
try {
|
try {
|
||||||
_reconnectAttempts++;
|
_reconnectAttempts++;
|
||||||
if (_reconnectAttempts > _maxReconnectAttempts) {
|
if (_reconnectAttempts > _maxReconnectAttempts) {
|
||||||
|
FirebaseCrashlytics.instance.log(
|
||||||
|
'Auth reconnection exhausted after $_maxReconnectAttempts attempts',
|
||||||
|
);
|
||||||
await logout();
|
await logout();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ import 'package:provider/provider.dart';
|
||||||
import 'package:url_launcher/url_launcher_string.dart';
|
import 'package:url_launcher/url_launcher_string.dart';
|
||||||
|
|
||||||
class ReleaseNotes extends StatefulWidget {
|
class ReleaseNotes extends StatefulWidget {
|
||||||
|
static const routeName = 'ReleaseNotes';
|
||||||
|
|
||||||
const ReleaseNotes({super.key});
|
const ReleaseNotes({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ class SettingsTileRoute extends StatelessWidget {
|
||||||
trailing: const Icon(Icons.chevron_right_rounded),
|
trailing: const Icon(Icons.chevron_right_rounded),
|
||||||
onTap: () => Navigator.of(context).push(
|
onTap: () => Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
|
settings: RouteSettings(name: title),
|
||||||
builder: (context) => useScaffold
|
builder: (context) => useScaffold
|
||||||
? _BlurredSettingsPage(title: title, child: child)
|
? _BlurredSettingsPage(title: title, child: child)
|
||||||
: child,
|
: child,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:frosty/apis/bttv_api.dart';
|
import 'package:frosty/apis/bttv_api.dart';
|
||||||
import 'package:frosty/apis/ffz_api.dart';
|
import 'package:frosty/apis/ffz_api.dart';
|
||||||
|
|
@ -166,10 +167,13 @@ abstract class GlobalAssetsStoreBase with Store {
|
||||||
required bool showFFZEmotes,
|
required bool showFFZEmotes,
|
||||||
required bool showFFZBadges,
|
required bool showFFZBadges,
|
||||||
}) async {
|
}) async {
|
||||||
// Error handler for emotes
|
void onAssetError(dynamic error, StackTrace stack) {
|
||||||
List<Emote> onEmoteError(dynamic error) {
|
debugPrint('GlobalAssetsStore error: $error');
|
||||||
debugPrint('GlobalAssetsStore emote error: $error');
|
FirebaseCrashlytics.instance.recordError(
|
||||||
return <Emote>[];
|
error,
|
||||||
|
stack,
|
||||||
|
reason: 'Global asset fetch failed',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await Future.wait([
|
await Future.wait([
|
||||||
|
|
@ -178,8 +182,9 @@ abstract class GlobalAssetsStoreBase with Store {
|
||||||
twitchApi
|
twitchApi
|
||||||
.getEmotesGlobal()
|
.getEmotesGlobal()
|
||||||
.then((emotes) => _twitchGlobalEmotes = emotes)
|
.then((emotes) => _twitchGlobalEmotes = emotes)
|
||||||
.catchError((e) {
|
.catchError((e, StackTrace stack) {
|
||||||
_twitchGlobalEmotes = onEmoteError(e);
|
onAssetError(e, stack);
|
||||||
|
_twitchGlobalEmotes = <Emote>[];
|
||||||
return _twitchGlobalEmotes;
|
return _twitchGlobalEmotes;
|
||||||
}),
|
}),
|
||||||
// 7TV global emotes
|
// 7TV global emotes
|
||||||
|
|
@ -187,8 +192,9 @@ abstract class GlobalAssetsStoreBase with Store {
|
||||||
sevenTVApi
|
sevenTVApi
|
||||||
.getEmotesGlobal()
|
.getEmotesGlobal()
|
||||||
.then((emotes) => _sevenTVGlobalEmotes = emotes)
|
.then((emotes) => _sevenTVGlobalEmotes = emotes)
|
||||||
.catchError((e) {
|
.catchError((e, StackTrace stack) {
|
||||||
_sevenTVGlobalEmotes = onEmoteError(e);
|
onAssetError(e, stack);
|
||||||
|
_sevenTVGlobalEmotes = <Emote>[];
|
||||||
return _sevenTVGlobalEmotes;
|
return _sevenTVGlobalEmotes;
|
||||||
}),
|
}),
|
||||||
// BTTV global emotes
|
// BTTV global emotes
|
||||||
|
|
@ -196,8 +202,9 @@ abstract class GlobalAssetsStoreBase with Store {
|
||||||
bttvApi
|
bttvApi
|
||||||
.getEmotesGlobal()
|
.getEmotesGlobal()
|
||||||
.then((emotes) => _bttvGlobalEmotes = emotes)
|
.then((emotes) => _bttvGlobalEmotes = emotes)
|
||||||
.catchError((e) {
|
.catchError((e, StackTrace stack) {
|
||||||
_bttvGlobalEmotes = onEmoteError(e);
|
onAssetError(e, stack);
|
||||||
|
_bttvGlobalEmotes = <Emote>[];
|
||||||
return _bttvGlobalEmotes;
|
return _bttvGlobalEmotes;
|
||||||
}),
|
}),
|
||||||
// FFZ global emotes
|
// FFZ global emotes
|
||||||
|
|
@ -205,8 +212,9 @@ abstract class GlobalAssetsStoreBase with Store {
|
||||||
ffzApi
|
ffzApi
|
||||||
.getEmotesGlobal()
|
.getEmotesGlobal()
|
||||||
.then((emotes) => _ffzGlobalEmotes = emotes)
|
.then((emotes) => _ffzGlobalEmotes = emotes)
|
||||||
.catchError((e) {
|
.catchError((e, StackTrace stack) {
|
||||||
_ffzGlobalEmotes = onEmoteError(e);
|
onAssetError(e, stack);
|
||||||
|
_ffzGlobalEmotes = <Emote>[];
|
||||||
return _ffzGlobalEmotes;
|
return _ffzGlobalEmotes;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|
@ -215,8 +223,8 @@ abstract class GlobalAssetsStoreBase with Store {
|
||||||
twitchApi
|
twitchApi
|
||||||
.getBadgesGlobal()
|
.getBadgesGlobal()
|
||||||
.then((badges) => _twitchGlobalBadges = badges)
|
.then((badges) => _twitchGlobalBadges = badges)
|
||||||
.catchError((e) {
|
.catchError((e, StackTrace stack) {
|
||||||
debugPrint('GlobalAssetsStore badge error: $e');
|
onAssetError(e, stack);
|
||||||
_twitchGlobalBadges = <String, ChatBadge>{};
|
_twitchGlobalBadges = <String, ChatBadge>{};
|
||||||
return _twitchGlobalBadges;
|
return _twitchGlobalBadges;
|
||||||
}),
|
}),
|
||||||
|
|
@ -224,8 +232,9 @@ abstract class GlobalAssetsStoreBase with Store {
|
||||||
if (showBTTVBadges)
|
if (showBTTVBadges)
|
||||||
bttvApi.getBadges().then((badges) => _bttvBadges = badges).catchError((
|
bttvApi.getBadges().then((badges) => _bttvBadges = badges).catchError((
|
||||||
e,
|
e,
|
||||||
|
StackTrace stack,
|
||||||
) {
|
) {
|
||||||
debugPrint('GlobalAssetsStore badge error: $e');
|
onAssetError(e, stack);
|
||||||
_bttvBadges = <String, ChatBadge>{};
|
_bttvBadges = <String, ChatBadge>{};
|
||||||
return _bttvBadges;
|
return _bttvBadges;
|
||||||
}),
|
}),
|
||||||
|
|
@ -233,8 +242,9 @@ abstract class GlobalAssetsStoreBase with Store {
|
||||||
if (showFFZBadges)
|
if (showFFZBadges)
|
||||||
ffzApi.getBadges().then((badges) => _ffzBadges = badges).catchError((
|
ffzApi.getBadges().then((badges) => _ffzBadges = badges).catchError((
|
||||||
e,
|
e,
|
||||||
|
StackTrace stack,
|
||||||
) {
|
) {
|
||||||
debugPrint('GlobalAssetsStore badge error: $e');
|
onAssetError(e, stack);
|
||||||
_ffzBadges = <String, List<ChatBadge>>{};
|
_ffzBadges = <String, List<ChatBadge>>{};
|
||||||
return _ffzBadges;
|
return _ffzBadges;
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue