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>
150 lines
4 KiB
Dart
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();
|
|
}
|