600 lines
19 KiB
Dart
600 lines
19 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)}',
|
|
);
|
|
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<SCVersionManageLatestRes?> _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<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);
|
|
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,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|