frosty/lib/screens/channel/video/stream_info_bar.dart
Tommy Chow 6ede000c66 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>
2026-03-08 00:18:21 -08:00

337 lines
14 KiB
Dart

import 'package:flutter/material.dart';
import 'package:frosty/models/channel.dart';
import 'package:frosty/models/stream.dart';
import 'package:frosty/screens/home/top/categories/category_streams.dart';
import 'package:frosty/theme.dart';
import 'package:frosty/utils.dart';
import 'package:frosty/utils/context_extensions.dart';
import 'package:frosty/widgets/live_indicator.dart';
import 'package:frosty/widgets/profile_picture.dart';
import 'package:frosty/widgets/uptime.dart';
import 'package:intl/intl.dart';
class StreamInfoBar extends StatelessWidget {
final StreamTwitch? streamInfo;
final Channel? offlineChannelInfo;
final bool showCategory;
final bool tappableCategory;
final bool showUptime;
final bool showViewerCount;
final bool showOfflineIndicator;
final EdgeInsets padding;
final TooltipTriggerMode tooltipTriggerMode;
final Color? textColor;
final bool isCompact;
final bool isInSharedChatMode;
final bool isOffline;
final bool showTextShadows;
final String? displayName;
const StreamInfoBar({
super.key,
this.streamInfo,
this.offlineChannelInfo,
this.showCategory = true,
this.tappableCategory = true,
this.showUptime = true,
this.showViewerCount = true,
this.showOfflineIndicator = true,
this.padding = EdgeInsets.zero,
this.tooltipTriggerMode = TooltipTriggerMode.tap,
this.textColor,
this.isCompact = false,
this.isInSharedChatMode = false,
this.isOffline = false,
this.showTextShadows = true,
this.displayName,
});
TextStyle _getBaseTextStyle(
BuildContext context,
double fontSize,
FontWeight fontWeight,
) {
return context.textTheme.bodyMedium?.copyWith(
fontSize: fontSize,
fontWeight: fontWeight,
color: textColor,
shadows: showTextShadows ? kOverlayShadow : null,
) ??
const TextStyle();
}
TextStyle _getSecondaryTextStyle(BuildContext context, double fontSize) {
return context.textTheme.bodyMedium?.copyWith(
fontSize: fontSize,
fontWeight: FontWeight.w500,
color:
textColor?.withValues(alpha: 0.7) ??
context.bodySmallColor?.withValues(alpha: 0.7),
shadows: showTextShadows ? kOverlayShadow : null,
) ??
const TextStyle();
}
@override
Widget build(BuildContext context) {
final streamTitle = isOffline
? (offlineChannelInfo?.title.trim() ?? '')
: (streamInfo?.title.trim() ?? '');
final streamerName = isOffline
? getReadableName(
offlineChannelInfo?.broadcasterName.isNotEmpty == true
? offlineChannelInfo?.broadcasterName ?? ''
: displayName ?? '',
offlineChannelInfo?.broadcasterLogin ?? '',
)
: getReadableName(
streamInfo?.userName ?? '',
streamInfo?.userLogin ?? '',
);
final secondLineSize = isCompact ? 13.0 : 14.0;
return Padding(
padding: padding,
child: Row(
spacing: 8,
children: [
Container(
decoration: isInSharedChatMode
? BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: Theme.of(context).colorScheme.primary,
width: 2,
),
)
: null,
child: Padding(
padding: isInSharedChatMode
? const EdgeInsets.all(1.5)
: EdgeInsets.zero,
child: ProfilePicture(
userLogin: isOffline
? (offlineChannelInfo?.broadcasterLogin.isNotEmpty == true
? offlineChannelInfo?.broadcasterLogin ?? ''
: displayName ?? '')
: (streamInfo?.userLogin ?? ''),
radius: 16,
),
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Top row: Streamer name + stream title
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
spacing: 4,
children: [
Tooltip(
message: streamerName,
triggerMode: tooltipTriggerMode,
child: Text(
streamerName,
style: _getBaseTextStyle(context, 14, FontWeight.w600),
overflow: TextOverflow.ellipsis,
),
),
if (streamTitle.isNotEmpty) ...[
Flexible(
child: Tooltip(
message: streamTitle,
triggerMode: tooltipTriggerMode,
child: Text(
streamTitle,
style: _getSecondaryTextStyle(context, 14),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
),
),
],
],
),
// Bottom row: Live indicator, uptime, viewer count, game name or Offline text
if (isOffline ||
(!isOffline && showUptime) ||
(!isOffline && showViewerCount) ||
(showCategory &&
(isOffline
? (offlineChannelInfo?.gameName.isNotEmpty ?? false)
: (streamInfo?.gameName.isNotEmpty ?? false)))) ...[
Row(
children: [
if (isOffline && showOfflineIndicator) ...[
Text(
'Offline',
style: _getSecondaryTextStyle(
context,
secondLineSize,
),
),
],
if (isOffline &&
showCategory &&
(offlineChannelInfo?.gameName.isNotEmpty ??
false)) ...[
if (showOfflineIndicator) const SizedBox(width: 8),
Icon(
Icons.gamepad,
size: secondLineSize,
color: (textColor ?? context.bodySmallColor)
?.withValues(alpha: 0.7),
shadows: kOverlayShadow,
),
const SizedBox(width: 4),
Flexible(
child: Tooltip(
message: offlineChannelInfo?.gameName ?? '',
triggerMode: tooltipTriggerMode,
child: tappableCategory
? GestureDetector(
onDoubleTap: () {
if (offlineChannelInfo
?.gameId
.isNotEmpty ??
false) {
Navigator.push(
context,
MaterialPageRoute(
settings: const RouteSettings(name: CategoryStreams.routeName),
builder: (context) =>
CategoryStreams(
categoryId:
offlineChannelInfo
?.gameId ??
'',
),
),
);
}
},
child: Text(
offlineChannelInfo?.gameName ?? '',
style: _getSecondaryTextStyle(
context,
secondLineSize,
),
overflow: TextOverflow.ellipsis,
),
)
: Text(
offlineChannelInfo?.gameName ?? '',
style: _getSecondaryTextStyle(
context,
secondLineSize,
),
overflow: TextOverflow.ellipsis,
),
),
),
] else ...[
if (!isOffline && (showUptime || showViewerCount)) ...[
const LiveIndicator(),
const SizedBox(width: 6),
],
if (!isOffline && showUptime) ...[
Uptime(
startTime:
streamInfo?.startedAt ??
DateTime.now().toIso8601String(),
style: _getBaseTextStyle(
context,
secondLineSize,
FontWeight.w500,
),
),
if (!isOffline && showViewerCount)
const SizedBox(width: 8),
],
if (!isOffline && showViewerCount) ...[
Icon(
Icons.visibility,
size: secondLineSize,
color: textColor ?? context.bodySmallColor,
shadows: kOverlayShadow,
),
const SizedBox(width: 4),
Text(
NumberFormat().format(streamInfo?.viewerCount ?? 0),
style: _getBaseTextStyle(
context,
secondLineSize,
FontWeight.w500,
),
),
],
if (!isOffline &&
showCategory &&
(streamInfo?.gameName.isNotEmpty ?? false) &&
(showUptime || showViewerCount)) ...[
const SizedBox(width: 8),
],
if (!isOffline &&
showCategory &&
(streamInfo?.gameName.isNotEmpty ?? false)) ...[
Icon(
Icons.gamepad,
size: secondLineSize,
color: textColor ?? context.bodySmallColor,
shadows: kOverlayShadow,
),
const SizedBox(width: 4),
Flexible(
child: Tooltip(
message: streamInfo?.gameName ?? '',
triggerMode: tooltipTriggerMode,
child: tappableCategory
? GestureDetector(
onDoubleTap: () => Navigator.push(
context,
MaterialPageRoute(
settings: const RouteSettings(name: CategoryStreams.routeName),
builder: (context) => CategoryStreams(
categoryId:
streamInfo?.gameId ?? '',
),
),
),
child: Text(
streamInfo?.gameName ?? '',
style: _getBaseTextStyle(
context,
secondLineSize,
FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
)
: Text(
streamInfo?.gameName ?? '',
style: _getBaseTextStyle(
context,
secondLineSize,
FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
),
),
],
],
],
),
],
],
),
),
],
),
);
}
}