frosty/lib/widgets/follow_channel_controller.dart
copilot 9a08c40136 Add Follow/Unfollow button under the channel player.
Show channel info and a follow control between video and chat in portrait, reusing the shared GQL follow path.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 09:49:45 +02:00

150 lines
4 KiB
Dart

import 'package:frosty/apis/base_api_client.dart';
import 'package:frosty/apis/twitch_integrity.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
/// Shared follow/unfollow state + GQL mutations for channel UI.
///
/// Owns loading/action flags and the session cache via [AuthStore.user].
/// Callers handle UI (integrity WebView, login prompt, snackbars).
class FollowChannelController {
final AuthStore authStore;
final String userId;
final bool? initialIsFollowing;
final void Function() onChanged;
bool? isFollowing;
bool loading = false;
bool actionInProgress = false;
FollowChannelController({
required this.authStore,
required this.userId,
this.initialIsFollowing,
required this.onChanged,
});
bool get isSelf =>
authStore.user.details?.id != null &&
authStore.user.details!.id == userId;
bool get canFollow => authStore.isLoggedIn && !isSelf;
/// Whether the follow control should be visible (loading or known state).
bool get shouldShow => canFollow && (loading || isFollowing != null);
void init() {
if (!canFollow) return;
if (initialIsFollowing != null) {
isFollowing = initialIsFollowing;
onChanged();
return;
}
loadFollowState();
}
Future<void> loadFollowState() async {
final cached = authStore.user.cachedIsFollowing(userId);
if (cached != null) {
isFollowing = cached;
onChanged();
return;
}
loading = true;
onChanged();
try {
final following = await authStore.user.isFollowingChannel(userId);
isFollowing = following;
} catch (_) {
// Leave isFollowing null so the control stays hidden.
} finally {
loading = false;
onChanged();
}
}
/// Toggles follow. Returns the new following state on success.
///
/// Throws [IntegrityCheckException] when a WebView integrity mint is needed,
/// [ApiException] for API errors, or [StateError] when not ready / no token.
Future<bool> toggle() async {
if (actionInProgress || isFollowing == null) {
throw StateError('Follow toggle not ready');
}
final gqlToken = authStore.gqlToken;
if (gqlToken == null) {
throw const FollowNeedsWebSessionException();
}
final following = isFollowing!;
actionInProgress = true;
onChanged();
try {
await _runMutation(following: following, gqlToken: gqlToken);
} catch (_) {
actionInProgress = false;
onChanged();
rethrow;
}
final nowFollowing = !following;
isFollowing = nowFollowing;
actionInProgress = false;
onChanged();
return nowFollowing;
}
/// After a WebView integrity mint, apply credentials and retry [toggle]'s
/// intended mutation (still based on the pre-toggle [wasFollowing] state).
Future<bool> retryAfterIntegrity({
required bool wasFollowing,
required GqlIntegrityCredentials credentials,
}) async {
final gqlToken = authStore.gqlToken;
if (gqlToken == null) {
throw const FollowNeedsWebSessionException();
}
actionInProgress = true;
onChanged();
try {
await authStore.user.applyWebIntegrity(credentials);
await _runMutation(following: wasFollowing, gqlToken: gqlToken);
} catch (_) {
actionInProgress = false;
onChanged();
rethrow;
}
final nowFollowing = !wasFollowing;
isFollowing = nowFollowing;
actionInProgress = false;
onChanged();
return nowFollowing;
}
Future<void> _runMutation({
required bool following,
required String gqlToken,
}) async {
if (following) {
await authStore.user.unfollow(targetId: userId, gqlToken: gqlToken);
} else {
await authStore.user.follow(targetId: userId, gqlToken: gqlToken);
}
}
void clearActionInProgress() {
if (!actionInProgress) return;
actionInProgress = false;
onChanged();
}
}
/// Thrown when follow needs a linked Twitch web (GQL) session.
class FollowNeedsWebSessionException implements Exception {
const FollowNeedsWebSessionException();
}