- 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>
174 lines
6 KiB
Dart
174 lines
6 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_mobx/flutter_mobx.dart';
|
|
import 'package:frosty/apis/base_api_client.dart';
|
|
import 'package:frosty/models/channel.dart';
|
|
import 'package:frosty/screens/channel/channel.dart';
|
|
import 'package:frosty/screens/home/search/search_store.dart';
|
|
import 'package:frosty/utils.dart';
|
|
import 'package:frosty/utils/modal_bottom_sheet.dart';
|
|
import 'package:frosty/widgets/alert_message.dart';
|
|
import 'package:frosty/widgets/live_indicator.dart';
|
|
import 'package:frosty/widgets/profile_picture.dart';
|
|
import 'package:frosty/widgets/skeleton_loader.dart';
|
|
import 'package:frosty/widgets/uptime.dart';
|
|
import 'package:frosty/widgets/user_actions_modal.dart';
|
|
import 'package:mobx/mobx.dart';
|
|
|
|
class SearchResultsChannels extends StatefulWidget {
|
|
final SearchStore searchStore;
|
|
final String query;
|
|
|
|
const SearchResultsChannels({
|
|
super.key,
|
|
required this.searchStore,
|
|
required this.query,
|
|
});
|
|
|
|
@override
|
|
State<SearchResultsChannels> createState() => _SearchResultsChannelsState();
|
|
}
|
|
|
|
class _SearchResultsChannelsState extends State<SearchResultsChannels> {
|
|
Future<void> _handleSearch(BuildContext context, String search) async {
|
|
try {
|
|
final channelInfo = await widget.searchStore.searchChannel(search);
|
|
|
|
if (!context.mounted) return;
|
|
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
settings: const RouteSettings(name: VideoChat.routeName),
|
|
builder: (context) => VideoChat(
|
|
userId: channelInfo.broadcasterId,
|
|
userName: channelInfo.broadcasterName,
|
|
userLogin: channelInfo.broadcasterLogin,
|
|
),
|
|
),
|
|
);
|
|
} on ApiException catch (e) {
|
|
debugPrint('Search channels ApiException: $e');
|
|
final snackBar = SnackBar(
|
|
content: AlertMessage(message: e.message, centered: false),
|
|
);
|
|
if (!context.mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(snackBar);
|
|
} catch (error) {
|
|
debugPrint('Search channels error: $error');
|
|
final snackBar = SnackBar(
|
|
content: AlertMessage(
|
|
message: 'Unable to find channel',
|
|
centered: false,
|
|
),
|
|
);
|
|
if (!context.mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(snackBar);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Observer(
|
|
builder: (context) {
|
|
final future = widget.searchStore.channelFuture;
|
|
|
|
// Show skeletons immediately while waiting for debounce.
|
|
if (future == null) {
|
|
if (widget.searchStore.isSearching) {
|
|
return SliverList.builder(
|
|
itemCount: 8,
|
|
itemBuilder: (context, index) =>
|
|
ChannelSkeletonLoader(index: index),
|
|
);
|
|
}
|
|
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
|
}
|
|
|
|
switch (future.status) {
|
|
case FutureStatus.pending:
|
|
return SliverList.builder(
|
|
itemCount: 8,
|
|
itemBuilder: (context, index) =>
|
|
ChannelSkeletonLoader(index: index),
|
|
);
|
|
case FutureStatus.rejected:
|
|
return const SliverToBoxAdapter(
|
|
child: SizedBox(
|
|
height: 100.0,
|
|
child: AlertMessage(
|
|
message: 'Unable to load channels',
|
|
vertical: true,
|
|
),
|
|
),
|
|
);
|
|
case FutureStatus.fulfilled:
|
|
final results = (future.result as List<ChannelQuery>).where(
|
|
(channel) => !widget.searchStore.authStore.user.blockedUsers
|
|
.map((blockedUser) => blockedUser.userId)
|
|
.contains(channel.id),
|
|
);
|
|
|
|
return SliverList.list(
|
|
children: [
|
|
...results.map((channel) {
|
|
final displayName = getReadableName(
|
|
channel.displayName,
|
|
channel.broadcasterLogin,
|
|
);
|
|
|
|
return InkWell(
|
|
onTap: () => Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
settings: const RouteSettings(name: VideoChat.routeName),
|
|
builder: (context) => VideoChat(
|
|
userId: channel.id,
|
|
userName: channel.displayName,
|
|
userLogin: channel.broadcasterLogin,
|
|
),
|
|
),
|
|
),
|
|
onLongPress: () {
|
|
HapticFeedback.mediumImpact();
|
|
|
|
showModalBottomSheetWithProperFocus(
|
|
context: context,
|
|
builder: (context) => UserActionsModal(
|
|
authStore: widget.searchStore.authStore,
|
|
name: displayName,
|
|
userLogin: channel.broadcasterLogin,
|
|
userId: channel.id,
|
|
),
|
|
);
|
|
},
|
|
child: ListTile(
|
|
title: Text(displayName),
|
|
leading: ProfilePicture(
|
|
userLogin: channel.broadcasterLogin,
|
|
radius: 16,
|
|
),
|
|
subtitle: channel.isLive
|
|
? Row(
|
|
spacing: 6,
|
|
children: [
|
|
const LiveIndicator(),
|
|
Uptime(startTime: channel.startedAt),
|
|
],
|
|
)
|
|
: null,
|
|
),
|
|
);
|
|
}),
|
|
ListTile(
|
|
title: Text('Go to channel "${widget.query}"'),
|
|
onTap: () => _handleSearch(context, widget.query),
|
|
trailing: const Icon(Icons.chevron_right_rounded),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
}
|