yumi-flutter/lib/shared/tools/sc_version_utils.dart
2026-06-03 20:34:50 +08:00

670 lines
21 KiB
Dart

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<bool> 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)}',
);
final review = _selectCurrentPlatformVersion(
versionInfo.review,
source: 'latestReview.review',
);
if (versionInfo.review != null) {
SCGlobalConfig.isReview =
review != null && "${review.version}" == SCGlobalConfig.version;
_debugLog(
'checkReview isReview=${SCGlobalConfig.isReview} '
'selectedReview=${_versionInfoDebug(review)}',
);
}
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<SCVersionManageLatestRes?> _resolveLatestVersion(
SCVersionManageLatestRes? latest,
) async {
final selectedLatest = _selectCurrentPlatformVersion(
latest,
source: 'latestReview.latest',
);
if (selectedLatest != null) {
return selectedLatest;
}
_debugLog(
'checkReview latestReview latest unavailable for current platform, '
'fallback releaseLatest',
);
try {
final fallback = await SCConfigRepositoryImp().versionManageLatest();
_debugLog('checkReview fallbackLatest=${_versionInfoDebug(fallback)}');
return _selectCurrentPlatformVersion(fallback, source: 'releaseLatest');
} catch (e) {
_debugLog('checkReview fallback releaseLatest failed error=$e');
return null;
}
}
static SCVersionManageLatestRes? _selectCurrentPlatformVersion(
SCVersionManageLatestRes? version, {
required String source,
}) {
if (_isVersionInfoEmpty(version)) {
_debugLog('checkReview ignore $source reason=empty');
return null;
}
final currentVersion = version!;
if (_isCurrentPlatformVersion(currentVersion)) {
_debugLog(
'checkReview select $source currentPlatform=${_currentPlatformName()} '
'version=${currentVersion.version} '
'buildVersion=${currentVersion.buildVersion} '
'downloadUrl=${currentVersion.downloadUrl}',
);
return currentVersion;
}
_debugLog(
'checkReview ignore $source reason=platform_mismatch '
'currentPlatform=${_currentPlatformName()} '
'responsePlatform=${currentVersion.platform} '
'version=${currentVersion.version} '
'buildVersion=${currentVersion.buildVersion} '
'downloadUrl=${currentVersion.downloadUrl}',
);
return null;
}
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 bool _isCurrentPlatformVersion(SCVersionManageLatestRes version) {
return _normalizePlatform(version.platform) == _currentPlatformKey();
}
static String _currentPlatformName() {
return Platform.isIOS ? 'iOS' : 'Android';
}
static String _currentPlatformKey() {
return Platform.isIOS ? 'ios' : 'android';
}
static String _normalizePlatform(String? platform) {
return (platform ?? '').trim().toLowerCase().replaceAll(
RegExp(r'[^a-z0-9]'),
'',
);
}
static List<int> _versionParts(String? version) {
final normalized = version?.trim() ?? "";
if (normalized.isEmpty) {
return const <int>[];
}
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<void>(
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<void> _openUpdateUrl(SCVersionManageLatestRes latest) async {
final url = _resolveDownloadUrl(latest);
final urlSource =
(latest.downloadUrl ?? "").trim().isNotEmpty
? 'api_downloadUrl'
: (Platform.isIOS ? 'local_appStore' : 'local_googlePlay');
_debugLog(
'openUpdateUrl currentPlatform=${_currentPlatformName()} '
'responsePlatform=${latest.platform} source=$urlSource url=$url',
);
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,
),
],
),
),
),
),
);
}
}