interact_controller.dart 11.1 KB
import 'dart:async';

import 'package:doublefeel_flutter/app/routes/app_pages.dart';
import 'package:doublefeel_flutter/app/modules/interact/models/interact_route_arguments.dart';
import 'package:doublefeel_flutter/app/modules/friends/data/friends_repository.dart';
import 'package:doublefeel_flutter/app/modules/friends/models/friend_health_data.dart';
import 'package:doublefeel_flutter/core/network/api/friend_api.dart';
import 'package:doublefeel_flutter/core/network/api/interaction_api.dart';
import 'package:doublefeel_flutter/core/result/app_result.dart';
import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
import 'package:doublefeel_flutter/data/models/interaction/interaction_models.dart';
import 'package:doublefeel_flutter/data/models/user/user_models.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:get/get.dart';

enum InteractAction { stick, miss, punch }

extension InteractionDataActionInteractAction on InteractionDataAction {
  /// Converts the action identifier returned in interaction data to the UI
  /// action used by the interaction page. Unknown server values are ignored.
  InteractAction? get interactAction => switch (action?.trim().toLowerCase()) {
    'poke' || 'stick' => InteractAction.stick,
    'miss' => InteractAction.miss,
    'punch' => InteractAction.punch,
    _ => null,
  };
}

extension InteractionRecordInteractAction on InteractionRecord {
  /// Record payloads identify the interaction by the numeric [actionType].
  InteractAction? get interactAction => switch (actionType) {
    0 => InteractAction.stick,
    1 => InteractAction.miss,
    2 => InteractAction.punch,
    _ => null,
  };
}

extension InteractActionUi on InteractAction {
  static const animationDuration = Duration(seconds: 5);

  int get apiValue => switch (this) {
    InteractAction.stick => 0,
    InteractAction.miss => 1,
    InteractAction.punch => 2,
  };

  String get title => switch (this) {
    InteractAction.stick => '戳一戳',
    InteractAction.miss => '想Ta',
    InteractAction.punch => '打一拳',
  };

  bool get needsVip => this != InteractAction.stick;

  String get iconAsset => switch (this) {
    InteractAction.stick =>
      'assets/images/interact/ic_friend_interact_poke.png',
    InteractAction.miss => 'assets/images/interact/ic_friend_interact_miss.png',
    InteractAction.punch =>
      'assets/images/interact/ic_friend_interact_punch.png',
  };

  String get lottieAsset => switch (this) {
    InteractAction.stick => 'assets/lottie/interaction_stick.json',
    InteractAction.miss => 'assets/lottie/interaction_miss.json',
    InteractAction.punch => 'assets/lottie/interaction_punch.json',
  };

  String get targetLottieAsset => switch (this) {
    InteractAction.stick => 'assets/lottie/interaction_stick_to.json',
    InteractAction.miss => 'assets/lottie/interaction_miss_to.json',
    InteractAction.punch => 'assets/lottie/interaction_punch_to.json',
  };
}

class InteractController extends GetxController {
  InteractController(
    this._interactionApi,
    this._userPreferencesStorage, {
    FriendsRepository? friendsRepository,
  }) : _friendsRepository =
           friendsRepository ?? FriendsRepositoryImpl(Get.find<FriendApi>());

  final InteractionApi _interactionApi;
  final UserPreferencesStorage _userPreferencesStorage;
  final FriendsRepository _friendsRepository;

  final records = <InteractionRecord>[].obs;
  final isLoading = false.obs;
  final sendingAction = Rxn<InteractAction>();
  final activeAction = Rxn<InteractAction>();
  final selectedFriend = Rxn<FriendHealthData>();
  final targetFriendUserId = RxnInt();
  final friends = <FriendHealthData>[].obs;
  InteractRouteArguments? entryArguments;

  UserInfoResponse? get me =>
      _userPreferencesStorage.preferences.value.meUserInfo;
  UserInfoResponse? get partner => selectedFriend.value == null
      ? _userPreferencesStorage.preferences.value.partnerUserInfo
      : null;
  bool get isInteractionLocked =>
      sendingAction.value != null || activeAction.value != null;
  bool get isVip =>
      _userPreferencesStorage.preferences.value.vipInfo?.isVip ?? false;
  List<FriendHealthData> get availableFriends =>
      friends.toList(growable: false);

  String get friendName =>
      selectedFriend.value?.name ?? partner?.nickname ?? '好友';
  String? get friendRemark => selectedFriend.value?.remark;
  String? get friendAvatarUrl =>
      selectedFriend.value?.avatarUrl ?? partner?.avatar;
  String get friendLabel {
    final remark = friendRemark?.trim();
    return remark == null || remark.isEmpty
        ? friendName
        : '$remark$friendName)';
  }

  String get selfName => me?.nickname ?? '我';
  String? get selfAvatarUrl => me?.avatar;

  /// V2 interaction types for the currently implemented home-page entries.
  int get interactionType {
    final values = entryArguments?.values;
    if (values == null || values.isEmpty) return 100;
    return switch (values.first.type) {
      InteractValueType.averageHrv => 101,
      InteractValueType.restingHeartRate => 102,
      InteractValueType.sleepDuration ||
      InteractValueType.sleepQualityScore ||
      InteractValueType.sleepQualityState ||
      InteractValueType.averageSleepHeartRate => 103,
      InteractValueType.activeCalories ||
      InteractValueType.exerciseDuration ||
      InteractValueType.standDuration => 104,
    };
  }

  @override
  void onInit() {
    super.onInit();
    final arguments = Get.arguments;
    if (arguments is InteractRouteArguments) {
      entryArguments = arguments;
      targetFriendUserId.value = arguments.friendUserId;
    }
    refreshPage();
  }

  /// Reload target profile and records together after switching friends.
  Future<void> refreshPage() async {
    await _loadSelectedFriend();
    await loadRecords();
  }

  Future<void> loadFriendCandidates() async {
    await _loadFriends();
    await _loadSelectedFriend();
  }

  Future<void> selectFriend(FriendHealthData friend) async {
    final userId = friend.userId;
    if (userId == null || userId == targetFriendUserId.value) return;
    targetFriendUserId.value = userId;
    selectedFriend.value = friend;
    await refreshPage();
  }

  Future<void> _loadSelectedFriend() async {
    await _loadFriends();
    var targetId = targetFriendUserId.value;
    if (targetId == null) {
      // Read directly from the friend source rather than the app-home state.
      final watchedFriend = availableFriends
          .where(
            (friend) =>
                friend.isOnWatchFace && friend.friendItem.friendUserId != null,
          )
          .firstOrNull;
      targetId =
          watchedFriend?.userId ?? watchedFriend?.friendItem.friendUserId;
      targetId ??= partner?.id;
      targetFriendUserId.value = targetId;
    }
    if (targetId == null) {
      selectedFriend.value = null;
      return;
    }
    final friend = availableFriends
        .where((friend) => friend.userId == targetId)
        .firstOrNull;
    if (friend != null) {
      selectedFriend.value = friend;
      return;
    }
    selectedFriend.value = null;
  }

  Future<void> _loadFriends() async {
    try {
      final data = await _friendsRepository.getFriendList();
      friends.assignAll(data.friends);
    } catch (_) {
      // Keep the existing snapshot during a transient list-request failure.
    }
  }

  Future<void> loadRecords() async {
    isLoading.value = true;
    final friendUserId = targetFriendUserId.value;
    if (friendUserId == null) {
      records.clear();
      isLoading.value = false;
      return;
    }
    final result = await _interactionApi.getInteractionRecordList(friendUserId);
    isLoading.value = false;
    switch (result) {
      case AppSuccess(:final data):
        records.assignAll(data.records ?? const <InteractionRecord>[]);
      case AppFailure():
      // Keep the last successful list visible when a refresh fails.
    }
  }

  Future<void> sendAction(
    InteractAction action, {
    InteractionDataAction? actionVariables,
  }) async {
    if (action.needsVip && !isVip) {
      _showToast('${action.title}为会员互动,请先开通会员');
      Get.toNamed(Routes.PURCHASE);
      return;
    }
    if (isInteractionLocked) return;
    final friendUserId = targetFriendUserId.value;
    if (friendUserId == null) {
      return;
    }

    sendingAction.value = action;
    final result = await _interactionApi.sendInteraction(
      InteractionData(
        friendUserId: friendUserId,
        interactionType: interactionType,
        actionType: action.apiValue,
        action: actionVariables,
      ),
    );
    sendingAction.value = null;

    switch (result) {
      case AppSuccess():
        activeAction.value = action;
        _showToast('${action.title}成功!');
        unawaited(loadRecords());
        Future<void>.delayed(InteractActionUi.animationDuration, () {
          if (activeAction.value == action) activeAction.value = null;
        });
      case AppFailure(:final error):
        _showToast('互动发送失败:$error');
    }
  }

  String barrageText(InteractionRecord record) => record.danmuText ?? '';

  InteractionRecordLine recordLine(InteractionRecord record) {
    final sentByMe = _isSentByMe(record);
    return InteractionRecordLine(
      template: l10n.interactRecordTextTemplate(
        sentByMe: sentByMe,
        action: record.interactAction?.name,
        interactionType: record.interactionType,
      ),
      actor: sentByMe ? l10n.interactRecordSelf : friendInteractionName,
      target: record.interactionType == 100
          ? friendInteractionName
          : sentByMe
          ? friendInteractionName
          : l10n.interactRecordYou,
      actionTarget: l10n.interactRecordTargetPronoun(sentByMe),
    );
  }

  bool _isSentByMe(InteractionRecord record) {
    final selfUserId = me?.id;
    if (selfUserId == null) return false;
    if (record.userId == selfUserId) return true;
    if (record.friendUserId == selfUserId) return false;
    return false;
  }

  String get friendInteractionName {
    final remark = friendRemark?.trim();
    return remark == null || remark.isEmpty
        ? friendName
        : '$remark($friendName)';
  }

  String recordTime(InteractionRecord record) {
    final timestamp = record.createTime;
    if (timestamp == null || timestamp <= 0) return '';
    final date = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000);
    return '${date.month.toString().padLeft(2, '0')}-'
        '${date.day.toString().padLeft(2, '0')} '
        '${date.hour.toString().padLeft(2, '0')}:'
        '${date.minute.toString().padLeft(2, '0')}';
  }

  void _showToast(String message) {
    Get.snackbar('互动', message, snackPosition: SnackPosition.BOTTOM);
  }
}

class InteractionRecordLine {
  static const actorToken = '##A##';
  static const targetToken = '##B##';
  static const actionTargetToken = '##C##';

  const InteractionRecordLine({
    required this.template,
    required this.actor,
    required this.target,
    required this.actionTarget,
  });

  final String template;
  final String actor;
  final String target;
  final String actionTarget;

  String get text => template
      .replaceAll(actorToken, actor)
      .replaceAll(targetToken, target)
      .replaceAll(actionTargetToken, actionTarget);
}