import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:yumi/app/constants/sc_global_config.dart'; import 'package:yumi/shared/business_logic/models/res/sc_version_manage_latest_res.dart'; import 'package:yumi/shared/data_sources/sources/repositories/sc_config_repository_imp.dart'; import 'package:yumi/shared/tools/sc_loading_manager.dart'; import 'package:yumi/shared/tools/sc_url_launcher_utils.dart'; class SCVersionUtils { static bool _forceUpdateDialogShowing = false; static Future checkReview({BuildContext? context}) async { _debugLog( 'checkReview start ' 'currentVersion=${SCGlobalConfig.version} ' 'currentBuild=${SCGlobalConfig.build} ' 'platform=${Platform.isIOS ? "ios" : "android"} ' 'apiHost=${SCGlobalConfig.apiHost} ' 'hasContext=${context != null} ' 'contextMounted=${context?.mounted}', ); try { var versionInfo = await SCConfigRepositoryImp().versionManageLatestReview(); _debugLog( 'checkReview response ' 'review=${_versionInfoDebug(versionInfo.review)} ' 'latest=${_versionInfoDebug(versionInfo.latest)}', ); if (versionInfo.review != null) { SCGlobalConfig.isReview = "${versionInfo.review?.version}" == SCGlobalConfig.version; _debugLog('checkReview isReview=${SCGlobalConfig.isReview}'); } final latest = await _resolveLatestVersion(versionInfo.latest); final shouldForceUpdate = latest != null && _shouldForceUpdate(latest); _debugLog( 'checkReview forceDecision ' 'shouldForceUpdate=$shouldForceUpdate ' 'reason=${_forceUpdateDecisionDebug(latest)}', ); if (context != null && context.mounted && latest != null && shouldForceUpdate) { SCLoadingManager.hide(); _debugLog('checkReview showForceUpdateDialog'); _showForceUpdateDialog(context, latest); return false; } if (shouldForceUpdate && context == null) { _debugLog('checkReview skip dialog reason=no_context'); } else if (shouldForceUpdate && context != null && context.mounted == false) { _debugLog('checkReview skip dialog reason=context_not_mounted'); } else { _debugLog('checkReview no force dialog'); } } catch (e) { _debugLog('checkReview request failed error=$e'); return true; } return true; } static bool _shouldForceUpdate(SCVersionManageLatestRes latest) { if (latest.forceUpdate != true) { return false; } final latestBuild = latest.buildVersion?.toInt() ?? 0; final currentBuild = _currentComparableBuild(); final hasLatestVersion = (latest.version ?? '').trim().isNotEmpty; final versionCompare = _compareVersion( latest.version, SCGlobalConfig.version, ); if (hasLatestVersion) { if (versionCompare > 0) { return true; } if (versionCompare < 0) { return false; } } if (latestBuild > 0 && currentBuild > 0) { return latestBuild > currentBuild; } return hasLatestVersion && versionCompare > 0; } static Future _resolveLatestVersion( SCVersionManageLatestRes? latest, ) async { if (!_isVersionInfoEmpty(latest)) { return latest; } _debugLog('checkReview latestReview latest empty, fallback releaseLatest'); try { final fallback = await SCConfigRepositoryImp().versionManageLatest(); _debugLog('checkReview fallbackLatest=${_versionInfoDebug(fallback)}'); return _isVersionInfoEmpty(fallback) ? latest : fallback; } catch (e) { _debugLog('checkReview fallback releaseLatest failed error=$e'); return latest; } } static bool _isVersionInfoEmpty(SCVersionManageLatestRes? version) { if (version == null) { return true; } return (version.id ?? '').trim().isEmpty && (version.platform ?? '').trim().isEmpty && (version.version ?? '').trim().isEmpty && version.forceUpdate == null && (version.appType ?? '').trim().isEmpty && version.review == null && version.patch == null && (version.updateDescribe ?? '').trim().isEmpty && (version.updateWorshipDescribe ?? '').trim().isEmpty && (version.downloadUrl ?? '').trim().isEmpty && version.buildVersion == null && version.createTime == null; } static int _compareVersion(String? latestVersion, String currentVersion) { final latestParts = _versionParts(latestVersion); final currentParts = _versionParts(currentVersion); final maxLength = latestParts.length > currentParts.length ? latestParts.length : currentParts.length; for (var i = 0; i < maxLength; i++) { final latestPart = i < latestParts.length ? latestParts[i] : 0; final currentPart = i < currentParts.length ? currentParts[i] : 0; if (latestPart != currentPart) { return latestPart.compareTo(currentPart); } } return 0; } static List _versionParts(String? version) { final normalized = version?.trim() ?? ""; if (normalized.isEmpty) { return const []; } return normalized .split(RegExp(r'[.+\-_]')) .map( (part) => int.tryParse(RegExp(r'^\d+').stringMatch(part) ?? '0') ?? 0, ) .toList(); } static int _currentRawBuild() { return int.tryParse(SCGlobalConfig.build.trim()) ?? 0; } static int _currentComparableBuild() { final rawBuild = _currentRawBuild(); if (!Platform.isAndroid || rawBuild < 1000) { return rawBuild; } // Flutter split-per-abi APKs offset Android versionCode by ABI, e.g. // build 132 becomes 2132 for arm64-v8a. The backend stores the base build. final baseBuild = rawBuild % 1000; return baseBuild > 0 ? baseBuild : rawBuild; } static String _currentBuildDebug() { final rawBuild = _currentRawBuild(); final comparableBuild = _currentComparableBuild(); if (rawBuild == comparableBuild) { return '$rawBuild'; } return '$comparableBuild(raw=$rawBuild)'; } static String _forceUpdateDecisionDebug(SCVersionManageLatestRes? latest) { if (latest == null) { return 'latest_null'; } final latestBuild = latest.buildVersion?.toInt() ?? 0; final currentBuild = _currentComparableBuild(); final currentBuildDebug = _currentBuildDebug(); final versionCompare = _compareVersion( latest.version, SCGlobalConfig.version, ); if (latest.forceUpdate != true) { return 'forceUpdate_not_true ' 'forceUpdate=${latest.forceUpdate} ' 'latestBuild=$latestBuild currentBuild=$currentBuildDebug ' 'versionCompare=$versionCompare'; } if (latestBuild > 0 && currentBuild > 0) { return 'compare_build ' 'latestBuild=$latestBuild currentBuild=$currentBuildDebug ' 'versionCompare=$versionCompare'; } return 'compare_version ' 'latestVersion=${latest.version} ' 'currentVersion=${SCGlobalConfig.version} ' 'versionCompare=$versionCompare ' 'latestBuild=$latestBuild currentBuild=$currentBuildDebug'; } static String _versionInfoDebug(SCVersionManageLatestRes? version) { if (version == null) { return 'null'; } return '{' 'id=${version.id}, ' 'platform=${version.platform}, ' 'version=${version.version}, ' 'buildVersion=${version.buildVersion}, ' 'forceUpdate=${version.forceUpdate}, ' 'review=${version.review}, ' 'patch=${version.patch}, ' 'downloadUrl=${version.downloadUrl}' '}'; } static void _debugLog(String message) { debugPrint('[SCVersionUtils] $message'); } static void _showForceUpdateDialog( BuildContext context, SCVersionManageLatestRes latest, ) { if (_forceUpdateDialogShowing || !context.mounted) { return; } _forceUpdateDialogShowing = true; unawaited( showDialog( context: context, barrierDismissible: false, barrierColor: Colors.black.withValues(alpha: 0.62), builder: (dialogContext) { final version = (latest.version ?? "").trim(); final updateDescribe = (latest.updateDescribe ?? "").trim(); final message = updateDescribe.isNotEmpty ? updateDescribe : "A newer version is required to keep voice rooms stable " "and secure."; return PopScope( canPop: false, child: Dialog( elevation: 0, backgroundColor: Colors.transparent, insetPadding: const EdgeInsets.symmetric(horizontal: 28), child: SCForceUpdateDialog( version: version, message: message, onUpdate: () { unawaited(_openUpdateUrl(latest)); }, ), ), ); }, ).whenComplete(() { _forceUpdateDialogShowing = false; }), ); } static Future _openUpdateUrl(SCVersionManageLatestRes latest) async { final url = _resolveDownloadUrl(latest); if (url.isEmpty) { return; } await SCUrlLauncherUtils.launchExternal(url); } static String _resolveDownloadUrl(SCVersionManageLatestRes latest) { final downloadUrl = (latest.downloadUrl ?? "").trim(); if (downloadUrl.isNotEmpty) { return downloadUrl; } return Platform.isIOS ? SCGlobalConfig.appDownloadUrlApple : SCGlobalConfig.appDownloadUrlGoogle; } } class SCForceUpdateDialog extends StatelessWidget { const SCForceUpdateDialog({ super.key, required this.version, required this.message, required this.onUpdate, }); final String version; final String message; final VoidCallback onUpdate; static const _asset = 'sc_images/general/sc_icon_app_update_bg.png'; static const _primary = Color(0xff18F2B1); static const _primaryDark = Color(0xff10C894); static const _titleColor = Color(0xff151823); static const _bodyColor = Color(0xff626775); @override Widget build(BuildContext context) { final constrainedTextScaler = MediaQuery.textScalerOf( context, ).clamp(maxScaleFactor: 1.15); return MediaQuery( data: MediaQuery.of(context).copyWith(textScaler: constrainedTextScaler), child: LayoutBuilder( builder: (context, constraints) { final width = constraints.maxWidth.isFinite ? constraints.maxWidth.clamp(280.0, 340.0) : 340.0; return Center( child: ConstrainedBox( constraints: BoxConstraints.tightFor(width: width), child: ClipRRect( borderRadius: BorderRadius.circular(28), child: DecoratedBox( decoration: BoxDecoration( color: Colors.white, boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.18), blurRadius: 34, offset: const Offset(0, 18), ), ], ), child: Column( mainAxisSize: MainAxisSize.min, children: [ SizedBox( height: 164, width: double.infinity, child: Image.asset( _asset, fit: BoxFit.cover, alignment: Alignment.topCenter, frameBuilder: ( context, child, frame, wasSynchronouslyLoaded, ) { if (wasSynchronouslyLoaded || frame != null) { return child; } return const _UpdateHeroFallback(); }, errorBuilder: (context, error, stackTrace) { return const _UpdateHeroFallback(); }, ), ), Padding( padding: const EdgeInsets.fromLTRB(24, 0, 24, 24), child: Column( mainAxisSize: MainAxisSize.min, children: [ _VersionPill(version: version), const SizedBox(height: 14), const Text( 'Update Required', textAlign: TextAlign.center, style: TextStyle( color: _titleColor, fontSize: 22, fontWeight: FontWeight.w800, height: 1.16, ), ), const SizedBox(height: 10), Text( message, maxLines: 3, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, style: const TextStyle( color: _bodyColor, fontSize: 14, fontWeight: FontWeight.w400, height: 1.45, ), ), const SizedBox(height: 18), const _UpdateNotice(), const SizedBox(height: 22), _UpdateButton(onPressed: onUpdate), ], ), ), ], ), ), ), ), ); }, ), ); } } class _UpdateHeroFallback extends StatelessWidget { const _UpdateHeroFallback(); @override Widget build(BuildContext context) { return DecoratedBox( decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Color(0xff14D6A1), Color(0xff65F3CD)], ), ), child: Stack( children: [ Positioned( left: 34, top: 26, child: Icon( Icons.auto_awesome_rounded, color: Colors.white.withValues(alpha: 0.72), size: 18, ), ), Positioned( right: 44, top: 36, child: Icon( Icons.auto_awesome_rounded, color: Colors.white.withValues(alpha: 0.58), size: 14, ), ), Center( child: Container( width: 86, height: 86, decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.92), shape: BoxShape.circle, boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.12), blurRadius: 18, offset: const Offset(0, 10), ), ], ), child: const Icon( Icons.rocket_launch_rounded, color: SCForceUpdateDialog._primaryDark, size: 46, ), ), ), ], ), ); } } class _VersionPill extends StatelessWidget { const _VersionPill({required this.version}); final String version; @override Widget build(BuildContext context) { final label = version.isEmpty ? 'New version available' : 'Version $version'; return DecoratedBox( decoration: BoxDecoration( color: SCForceUpdateDialog._primary.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(999), border: Border.all( color: SCForceUpdateDialog._primary.withValues(alpha: 0.28), ), ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), child: Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: SCForceUpdateDialog._primaryDark, fontSize: 12, fontWeight: FontWeight.w700, height: 1.1, ), ), ), ); } } class _UpdateNotice extends StatelessWidget { const _UpdateNotice(); @override Widget build(BuildContext context) { return DecoratedBox( decoration: BoxDecoration( color: const Color(0xffF6F8FA), borderRadius: BorderRadius.circular(16), ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), child: const Text( 'This RTC version has expired. Please update to the latest ' 'version to continue using voice rooms normally.', textAlign: TextAlign.center, style: TextStyle( color: SCForceUpdateDialog._titleColor, fontSize: 13, fontWeight: FontWeight.w600, height: 1.36, ), ), ), ); } } class _UpdateButton extends StatelessWidget { const _UpdateButton({required this.onPressed}); final VoidCallback onPressed; @override Widget build(BuildContext context) { return Material( color: Colors.transparent, child: Ink( height: 50, decoration: BoxDecoration( gradient: const LinearGradient( colors: [SCForceUpdateDialog._primary, Color(0xff76FFD8)], ), borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( color: SCForceUpdateDialog._primary.withValues(alpha: 0.34), blurRadius: 16, offset: const Offset(0, 8), ), ], ), child: InkWell( onTap: onPressed, borderRadius: BorderRadius.circular(16), child: const Center( child: Row( mainAxisSize: MainAxisSize.min, children: [ Text( 'Update Now', style: TextStyle( color: Color(0xff10131A), fontSize: 16, fontWeight: FontWeight.w800, height: 1, ), ), SizedBox(width: 8), Icon( Icons.arrow_forward_rounded, color: Color(0xff10131A), size: 20, ), ], ), ), ), ), ); } }