Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f232c4d10a | ||
|
|
25dc98f199 | ||
|
|
804b83af66 | ||
|
|
97e8abace8 | ||
|
|
294f3d8af6 | ||
|
|
c6887b6583 | ||
|
|
c9704ce53e | ||
|
|
ae3550e669 |
@@ -795,7 +795,7 @@ fun computeApplicationId(): String {
|
|||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "io.openim.flutter.openim"
|
namespace = "io.openim.flutter.openim"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = 36
|
||||||
ndkVersion = "27.0.12077973" // 使用已存在的NDK版本,避免下载问题
|
ndkVersion = "27.0.12077973" // 使用已存在的NDK版本,避免下载问题
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
@@ -948,7 +948,7 @@ android {
|
|||||||
|
|
||||||
// You can update the following values to match your application needs.
|
// You can update the following values to match your application needs.
|
||||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||||
minSdk = flutter.minSdkVersion
|
minSdk = 23
|
||||||
targetSdk = flutter.targetSdkVersion
|
targetSdk = flutter.targetSdkVersion
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import android.util.Base64
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import android.view.autofill.AutofillManager
|
||||||
import android.webkit.CookieManager
|
import android.webkit.CookieManager
|
||||||
import android.webkit.WebStorage
|
import android.webkit.WebStorage
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
@@ -59,6 +60,13 @@ class MainActivity : FlutterActivity() {
|
|||||||
disableAutofillForCurrentWindow()
|
disableAutofillForCurrentWindow()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onWindowFocusChanged(hasFocus: Boolean) {
|
||||||
|
super.onWindowFocusChanged(hasFocus)
|
||||||
|
if (hasFocus) {
|
||||||
|
disableAutofillForCurrentWindow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
pendingCaptureConfirmationDialog?.dismiss()
|
pendingCaptureConfirmationDialog?.dismiss()
|
||||||
pendingCaptureConfirmationDialog = null
|
pendingCaptureConfirmationDialog = null
|
||||||
@@ -405,21 +413,18 @@ class MainActivity : FlutterActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun installAutofillBlocker() {
|
private fun installAutofillBlocker() {
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
disableAutofillForCurrentWindow()
|
disableAutofillForCurrentWindow()
|
||||||
window.decorView.viewTreeObserver.addOnGlobalLayoutListener {
|
window.decorView.viewTreeObserver.addOnGlobalLayoutListener {
|
||||||
disableAutofillForCurrentWindow()
|
disableAutofillForCurrentWindow()
|
||||||
}
|
}
|
||||||
|
window.decorView.post {
|
||||||
|
disableAutofillForCurrentWindow()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun disableAutofillForCurrentWindow() {
|
private fun disableAutofillForCurrentWindow() {
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
disableAutofill(window.decorView)
|
disableAutofill(window.decorView)
|
||||||
|
cancelAutofillSession()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun disableAutofill(view: View) {
|
private fun disableAutofill(view: View) {
|
||||||
@@ -427,6 +432,10 @@ class MainActivity : FlutterActivity() {
|
|||||||
view.importantForAutofill = View.IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS
|
view.importantForAutofill = View.IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (view is WebView) {
|
||||||
|
disableWebViewAutofill(view)
|
||||||
|
}
|
||||||
|
|
||||||
if (view is ViewGroup) {
|
if (view is ViewGroup) {
|
||||||
for (index in 0 until view.childCount) {
|
for (index in 0 until view.childCount) {
|
||||||
disableAutofill(view.getChildAt(index))
|
disableAutofill(view.getChildAt(index))
|
||||||
@@ -434,6 +443,35 @@ class MainActivity : FlutterActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun disableWebViewAutofill(webView: WebView) {
|
||||||
|
webView.settings.setSaveFormData(false)
|
||||||
|
webView.clearFormData()
|
||||||
|
webView.isSaveEnabled = false
|
||||||
|
webView.setSaveFromParentEnabled(false)
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
webView.importantForAutofill =
|
||||||
|
View.IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS
|
||||||
|
}
|
||||||
|
|
||||||
|
webView.post {
|
||||||
|
webView.settings.setSaveFormData(false)
|
||||||
|
webView.clearFormData()
|
||||||
|
webView.isSaveEnabled = false
|
||||||
|
webView.setSaveFromParentEnabled(false)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
webView.importantForAutofill =
|
||||||
|
View.IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelAutofillSession() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
getSystemService(AutofillManager::class.java)?.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun normalizeMimeTypes(acceptTypes: List<String>): List<String> {
|
private fun normalizeMimeTypes(acceptTypes: List<String>): List<String> {
|
||||||
val mimeTypes = linkedSetOf<String>()
|
val mimeTypes = linkedSetOf<String>()
|
||||||
acceptTypes
|
acceptTypes
|
||||||
|
|||||||
@@ -114,10 +114,11 @@ class AppConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static String _randomWildcardLabel() {
|
static String _randomWildcardLabel() {
|
||||||
final digits = StringBuffer(_random.nextInt(9) + 1);
|
const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
for (var index = 1; index < 16; index += 1) {
|
final label = StringBuffer();
|
||||||
digits.write(_random.nextInt(10));
|
for (var index = 0; index < 6; index += 1) {
|
||||||
|
label.write(alphabet[_random.nextInt(alphabet.length)]);
|
||||||
}
|
}
|
||||||
return digits.toString();
|
return label.toString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
445
lib/main.dart
445
lib/main.dart
@@ -2,6 +2,8 @@ import 'dart:async';
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/gestures.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
@@ -17,28 +19,17 @@ const _shellAccent = Color(0xFF168CFF);
|
|||||||
const _shellAccentDeep = Color(0xFF0066D9);
|
const _shellAccentDeep = Color(0xFF0066D9);
|
||||||
const _shellInk = Color(0xFF17233D);
|
const _shellInk = Color(0xFF17233D);
|
||||||
const _shellSubText = Color(0xFF7C8AA3);
|
const _shellSubText = Color(0xFF7C8AA3);
|
||||||
const _shellBrandingChannel =
|
const _shellBrandingChannel = MethodChannel(
|
||||||
MethodChannel('io.openim.flutter.im_webview_app/shell_branding');
|
'io.openim.flutter.im_webview_app/shell_branding',
|
||||||
const _androidFilePickerChannel =
|
);
|
||||||
MethodChannel('io.openim.flutter.openim/file_picker');
|
const _androidFilePickerChannel = MethodChannel(
|
||||||
const _h5CacheChannel =
|
'io.openim.flutter.openim/file_picker',
|
||||||
MethodChannel('io.openim.flutter.im_webview_app/h5_cache');
|
);
|
||||||
const _keyboardAnimationDuration = Duration(milliseconds: 250);
|
const _keyboardAnimationDuration = Duration(milliseconds: 250);
|
||||||
const _initialH5CacheClearedKey = 'initial_h5_cache_cleared_v1';
|
|
||||||
const _h5DomainCacheDateKey = 'h5_random_domain_date_v1';
|
|
||||||
const _h5DomainCacheSessionKey = 'h5_random_domain_session_v1';
|
|
||||||
const _h5DomainCacheUrlKey = 'h5_random_domain_url_v1';
|
const _h5DomainCacheUrlKey = 'h5_random_domain_url_v1';
|
||||||
const _lineProbeTimeout = Duration(seconds: 5);
|
const _lineProbeTimeout = Duration(seconds: 5);
|
||||||
const _maxGeneratedLineAttempts = 6;
|
const _maxGeneratedLineAttempts = 6;
|
||||||
final String _h5DomainProcessSession =
|
const _longBackgroundRefreshThreshold = Duration(minutes: 20);
|
||||||
'session-${DateTime.now().microsecondsSinceEpoch}';
|
|
||||||
|
|
||||||
String _domainDateKey(DateTime value) {
|
|
||||||
final year = value.year.toString().padLeft(4, '0');
|
|
||||||
final month = value.month.toString().padLeft(2, '0');
|
|
||||||
final day = value.day.toString().padLeft(2, '0');
|
|
||||||
return '$year-$month-$day';
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
@@ -54,10 +45,7 @@ Future<void> main() async {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class ShellBranding {
|
class ShellBranding {
|
||||||
const ShellBranding({
|
const ShellBranding({required this.appName, required this.appLogo});
|
||||||
required this.appName,
|
|
||||||
required this.appLogo,
|
|
||||||
});
|
|
||||||
|
|
||||||
static const fallback = ShellBranding(
|
static const fallback = ShellBranding(
|
||||||
appName: AppConfig.appName,
|
appName: AppConfig.appName,
|
||||||
@@ -69,8 +57,9 @@ class ShellBranding {
|
|||||||
|
|
||||||
static Future<ShellBranding> load() async {
|
static Future<ShellBranding> load() async {
|
||||||
try {
|
try {
|
||||||
final data = await _shellBrandingChannel
|
final data = await _shellBrandingChannel.invokeMapMethod<String, String>(
|
||||||
.invokeMapMethod<String, String>('getShellBranding');
|
'getShellBranding',
|
||||||
|
);
|
||||||
final appName = _trim(data?['appName']);
|
final appName = _trim(data?['appName']);
|
||||||
final appLogo = _trim(data?['appLogo']);
|
final appLogo = _trim(data?['appLogo']);
|
||||||
|
|
||||||
@@ -86,17 +75,10 @@ class ShellBranding {
|
|||||||
static String _trim(String? value) => value?.trim() ?? '';
|
static String _trim(String? value) => value?.trim() ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
enum _LineAvailability {
|
enum _LineAvailability { checking, available, unavailable }
|
||||||
checking,
|
|
||||||
available,
|
|
||||||
unavailable,
|
|
||||||
}
|
|
||||||
|
|
||||||
class ImWebViewApp extends StatelessWidget {
|
class ImWebViewApp extends StatelessWidget {
|
||||||
const ImWebViewApp({
|
const ImWebViewApp({super.key, this.shellBranding = ShellBranding.fallback});
|
||||||
super.key,
|
|
||||||
this.shellBranding = ShellBranding.fallback,
|
|
||||||
});
|
|
||||||
|
|
||||||
final ShellBranding shellBranding;
|
final ShellBranding shellBranding;
|
||||||
|
|
||||||
@@ -125,6 +107,11 @@ class H5ShellPage extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver {
|
class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver {
|
||||||
|
static final Set<Factory<OneSequenceGestureRecognizer>>
|
||||||
|
_webViewGestureRecognizers = <Factory<OneSequenceGestureRecognizer>>{
|
||||||
|
Factory<EagerGestureRecognizer>(() => EagerGestureRecognizer()),
|
||||||
|
};
|
||||||
|
|
||||||
late H5Line _h5Line;
|
late H5Line _h5Line;
|
||||||
late _H5LineWebViewSlot _lineSlot;
|
late _H5LineWebViewSlot _lineSlot;
|
||||||
|
|
||||||
@@ -136,10 +123,9 @@ class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver {
|
|||||||
int _keyboardSyncToken = 0;
|
int _keyboardSyncToken = 0;
|
||||||
int _lineGeneration = 0;
|
int _lineGeneration = 0;
|
||||||
int _h5LineAttempt = 0;
|
int _h5LineAttempt = 0;
|
||||||
|
int _resumeRecoveryToken = 0;
|
||||||
|
DateTime? _backgroundedAt;
|
||||||
late ShellBranding _shellBranding;
|
late ShellBranding _shellBranding;
|
||||||
Future<void>? _cacheClearTask;
|
|
||||||
Timer? _domainExpiryTimer;
|
|
||||||
String _activeDomainDateKey = '';
|
|
||||||
|
|
||||||
_H5LineWebViewSlot get _currentSlot => _lineSlot;
|
_H5LineWebViewSlot get _currentSlot => _lineSlot;
|
||||||
|
|
||||||
@@ -150,7 +136,6 @@ class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver {
|
|||||||
_h5Line = AppConfig.h5Line;
|
_h5Line = AppConfig.h5Line;
|
||||||
_shellBranding = widget.initialShellBranding;
|
_shellBranding = widget.initialShellBranding;
|
||||||
_lineSlot = _createLineSlot(_h5Line);
|
_lineSlot = _createLineSlot(_h5Line);
|
||||||
_scheduleDomainExpiryTimer();
|
|
||||||
unawaited(_initializeLines());
|
unawaited(_initializeLines());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,19 +156,15 @@ class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver {
|
|||||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||||
..addJavaScriptChannel(
|
..addJavaScriptChannel(
|
||||||
'OpenIMShell',
|
'OpenIMShell',
|
||||||
onMessageReceived: (message) => _handleShellMessage(
|
onMessageReceived: (message) =>
|
||||||
generation,
|
_handleShellMessage(generation, message),
|
||||||
message,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
..addJavaScriptChannel(
|
..addJavaScriptChannel(
|
||||||
'OpenIMFlutterShell',
|
'OpenIMFlutterShell',
|
||||||
onMessageReceived: (message) => _handleFlutterShellMessage(
|
onMessageReceived: (message) =>
|
||||||
generation,
|
_handleFlutterShellMessage(generation, message),
|
||||||
message,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
..setBackgroundColor(Colors.white)
|
..setBackgroundColor(Colors.transparent)
|
||||||
..setNavigationDelegate(
|
..setNavigationDelegate(
|
||||||
NavigationDelegate(
|
NavigationDelegate(
|
||||||
onProgress: (progress) {
|
onProgress: (progress) {
|
||||||
@@ -252,14 +233,12 @@ class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final result = await _androidFilePickerChannel.invokeListMethod<String>(
|
final result = await _androidFilePickerChannel
|
||||||
'pickFiles',
|
.invokeListMethod<String>('pickFiles', {
|
||||||
{
|
|
||||||
'acceptTypes': params.acceptTypes,
|
'acceptTypes': params.acceptTypes,
|
||||||
'allowMultiple': params.mode == FileSelectorMode.openMultiple,
|
'allowMultiple': params.mode == FileSelectorMode.openMultiple,
|
||||||
'capture': params.isCaptureEnabled,
|
'capture': params.isCaptureEnabled,
|
||||||
},
|
});
|
||||||
);
|
|
||||||
return result ?? <String>[];
|
return result ?? <String>[];
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return <String>[];
|
return <String>[];
|
||||||
@@ -269,20 +248,29 @@ class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver {
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
_domainExpiryTimer?.cancel();
|
|
||||||
_lineSlot.dispose();
|
_lineSlot.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
if (state == AppLifecycleState.paused ||
|
||||||
|
state == AppLifecycleState.inactive ||
|
||||||
|
state == AppLifecycleState.detached) {
|
||||||
|
_backgroundedAt ??= DateTime.now();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (state == AppLifecycleState.resumed) {
|
if (state == AppLifecycleState.resumed) {
|
||||||
unawaited(_refreshExpiredH5LinesIfNeeded());
|
final backgroundDuration = _backgroundedAt == null
|
||||||
|
? Duration.zero
|
||||||
|
: DateTime.now().difference(_backgroundedAt!);
|
||||||
|
_backgroundedAt = null;
|
||||||
|
unawaited(_recoverWebViewAfterResume(backgroundDuration));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _initializeLines() async {
|
Future<void> _initializeLines() async {
|
||||||
await _clearInitialH5CachesIfNeeded();
|
|
||||||
await _prepareAndLoadH5Line(forceNew: false);
|
await _prepareAndLoadH5Line(forceNew: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,7 +295,6 @@ class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver {
|
|||||||
_isPreparingInitialLine = false;
|
_isPreparingInitialLine = false;
|
||||||
});
|
});
|
||||||
await _loadCurrentLine(forceReload: true);
|
await _loadCurrentLine(forceReload: true);
|
||||||
_scheduleDomainExpiryTimer();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,40 +310,25 @@ class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver {
|
|||||||
_currentSlot.loadError = '当前线路暂不可用,请稍后重试';
|
_currentSlot.loadError = '当前线路暂不可用,请稍后重试';
|
||||||
_currentSlot.showShellCover = false;
|
_currentSlot.showShellCover = false;
|
||||||
});
|
});
|
||||||
_scheduleDomainExpiryTimer();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<H5Line> _loadCachedOrCreateH5Line({
|
Future<H5Line> _loadCachedOrCreateH5Line({required bool forceNew}) async {
|
||||||
required bool forceNew,
|
|
||||||
}) async {
|
|
||||||
final today = _domainDateKey(DateTime.now());
|
|
||||||
_activeDomainDateKey = today;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final preferences = await SharedPreferences.getInstance();
|
final preferences = await SharedPreferences.getInstance();
|
||||||
final cachedUrl = preferences.getString(_h5DomainCacheUrlKey);
|
final cachedUrl = preferences.getString(_h5DomainCacheUrlKey);
|
||||||
final cachedLine =
|
final cachedLine =
|
||||||
cachedUrl == null ? null : AppConfig.h5LineFromUrl(cachedUrl);
|
cachedUrl == null ? null : AppConfig.h5LineFromUrl(cachedUrl);
|
||||||
final canUseCachedLine = !forceNew &&
|
final canUseCachedLine = !forceNew && cachedLine != null;
|
||||||
preferences.getString(_h5DomainCacheDateKey) == today &&
|
|
||||||
preferences.getString(_h5DomainCacheSessionKey) ==
|
|
||||||
_h5DomainProcessSession &&
|
|
||||||
cachedLine != null;
|
|
||||||
|
|
||||||
if (canUseCachedLine) {
|
if (canUseCachedLine) {
|
||||||
_logH5Line('复用本进程当天随机线路', cachedLine);
|
_logH5Line('复用已保存 H5 线路', cachedLine);
|
||||||
return cachedLine;
|
return cachedLine;
|
||||||
}
|
}
|
||||||
|
|
||||||
final line = AppConfig.createRandomH5Line(attempt: _h5LineAttempt);
|
final line = AppConfig.createRandomH5Line(attempt: _h5LineAttempt);
|
||||||
_h5LineAttempt += 1;
|
_h5LineAttempt += 1;
|
||||||
await preferences.setString(_h5DomainCacheDateKey, today);
|
|
||||||
await preferences.setString(
|
|
||||||
_h5DomainCacheSessionKey,
|
|
||||||
_h5DomainProcessSession,
|
|
||||||
);
|
|
||||||
await preferences.setString(_h5DomainCacheUrlKey, line.url);
|
await preferences.setString(_h5DomainCacheUrlKey, line.url);
|
||||||
_logH5Line('生成当天随机线路', line);
|
_logH5Line('生成并保存 H5 线路', line);
|
||||||
return line;
|
return line;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
final line = AppConfig.createRandomH5Line(attempt: _h5LineAttempt);
|
final line = AppConfig.createRandomH5Line(attempt: _h5LineAttempt);
|
||||||
@@ -366,132 +338,6 @@ class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _refreshExpiredH5LinesIfNeeded() async {
|
|
||||||
final today = _domainDateKey(DateTime.now());
|
|
||||||
if (_isPreparingInitialLine || _activeDomainDateKey == today) {
|
|
||||||
_scheduleDomainExpiryTimer();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_isPreparingInitialLine = true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await _clearAllH5Caches();
|
|
||||||
await _prepareAndLoadH5Line(forceNew: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _scheduleDomainExpiryTimer() {
|
|
||||||
_domainExpiryTimer?.cancel();
|
|
||||||
|
|
||||||
final now = DateTime.now();
|
|
||||||
final nextMidnight = DateTime(now.year, now.month, now.day + 1);
|
|
||||||
final delay = nextMidnight.difference(now) + const Duration(seconds: 1);
|
|
||||||
_domainExpiryTimer = Timer(
|
|
||||||
delay,
|
|
||||||
() => unawaited(_refreshExpiredH5LinesIfNeeded()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _clearInitialH5CachesIfNeeded() async {
|
|
||||||
try {
|
|
||||||
final preferences = await SharedPreferences.getInstance();
|
|
||||||
if (preferences.getBool(_initialH5CacheClearedKey) == true) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await _clearAllH5Caches();
|
|
||||||
await preferences.setBool(_initialH5CacheClearedKey, true);
|
|
||||||
} catch (_) {
|
|
||||||
await _clearAllH5Caches();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _clearAllH5Caches() {
|
|
||||||
final runningTask = _cacheClearTask;
|
|
||||||
if (runningTask != null) {
|
|
||||||
return runningTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
final task = _clearAllH5CachesInternal();
|
|
||||||
_cacheClearTask = task;
|
|
||||||
return task.whenComplete(() {
|
|
||||||
if (identical(_cacheClearTask, task)) {
|
|
||||||
_cacheClearTask = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _clearAllH5CachesInternal() async {
|
|
||||||
await _clearNativeH5WebsiteData();
|
|
||||||
await _clearCookiesSafely();
|
|
||||||
await _clearPageStorage(_lineSlot.controller);
|
|
||||||
await _clearControllerStorage(_lineSlot.controller);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _clearNativeH5WebsiteData() async {
|
|
||||||
try {
|
|
||||||
await _h5CacheChannel.invokeMethod<void>('clearAllWebsiteData');
|
|
||||||
} catch (_) {
|
|
||||||
// Older native shells may not expose the cache channel yet.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _clearCookiesSafely() async {
|
|
||||||
try {
|
|
||||||
await WebViewCookieManager().clearCookies();
|
|
||||||
} catch (_) {
|
|
||||||
// Cookie clearing is best-effort across platform WebView implementations.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _clearControllerStorage(WebViewController controller) async {
|
|
||||||
try {
|
|
||||||
await controller.clearCache();
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await controller.clearLocalStorage();
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _clearPageStorage(WebViewController controller) async {
|
|
||||||
const script = '''
|
|
||||||
(() => {
|
|
||||||
try {
|
|
||||||
if ('caches' in window) {
|
|
||||||
caches.keys().then((keys) => Promise.all(keys.map((key) => caches.delete(key))));
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
try {
|
|
||||||
if ('serviceWorker' in navigator) {
|
|
||||||
navigator.serviceWorker.getRegistrations()
|
|
||||||
.then((items) => Promise.all(items.map((item) => item.unregister())));
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
try {
|
|
||||||
if (typeof indexedDB !== 'undefined' && indexedDB.databases) {
|
|
||||||
indexedDB.databases().then((databases) => {
|
|
||||||
databases.forEach((database) => {
|
|
||||||
if (database && database.name) {
|
|
||||||
indexedDB.deleteDatabase(database.name);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
try { window.localStorage.clear(); } catch (_) {}
|
|
||||||
try { window.sessionStorage.clear(); } catch (_) {}
|
|
||||||
})();
|
|
||||||
''';
|
|
||||||
|
|
||||||
try {
|
|
||||||
await controller.runJavaScript(script);
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> _probeCurrentLine() async {
|
Future<bool> _probeCurrentLine() async {
|
||||||
final generation = _lineGeneration;
|
final generation = _lineGeneration;
|
||||||
final line = _lineSlot.line;
|
final line = _lineSlot.line;
|
||||||
@@ -629,6 +475,8 @@ class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver {
|
|||||||
_hideShellCover(generation);
|
_hideShellCover(generation);
|
||||||
} else if (decoded is Map && decoded['type'] == 'keyboard-bridge-ready') {
|
} else if (decoded is Map && decoded['type'] == 'keyboard-bridge-ready') {
|
||||||
unawaited(_syncKeyboardState());
|
unawaited(_syncKeyboardState());
|
||||||
|
} else if (decoded is Map && decoded['type'] == 'openExternalUrl') {
|
||||||
|
unawaited(_openExternalUrl(decoded['url']?.toString()));
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
if (message.message == 'first-screen-ready') {
|
if (message.message == 'first-screen-ready') {
|
||||||
@@ -666,6 +514,8 @@ class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver {
|
|||||||
unawaited(openAppSettings());
|
unawaited(openAppSettings());
|
||||||
case 'keyboard-bridge-ready':
|
case 'keyboard-bridge-ready':
|
||||||
unawaited(_syncKeyboardState());
|
unawaited(_syncKeyboardState());
|
||||||
|
case 'openExternalUrl':
|
||||||
|
unawaited(_openExternalUrl(decoded['url']?.toString()));
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Ignore malformed shell messages from web content.
|
// Ignore malformed shell messages from web content.
|
||||||
@@ -802,6 +652,97 @@ class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver {
|
|||||||
return _runJavaScriptSafely(script);
|
return _runJavaScriptSafely(script);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _notifyH5AppResumed(Duration backgroundDuration) {
|
||||||
|
final payload = jsonEncode({
|
||||||
|
'backgroundDurationMs': backgroundDuration.inMilliseconds,
|
||||||
|
'refreshedAt': DateTime.now().millisecondsSinceEpoch,
|
||||||
|
});
|
||||||
|
final script = '''
|
||||||
|
(() => {
|
||||||
|
try {
|
||||||
|
const detail = $payload;
|
||||||
|
window.dispatchEvent(new CustomEvent('openim-shell-app-resumed', { detail }));
|
||||||
|
} catch (_) {}
|
||||||
|
})();
|
||||||
|
''';
|
||||||
|
return _runJavaScriptSafely(script);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _isH5RuntimeResponsive() async {
|
||||||
|
try {
|
||||||
|
final result = await _lineSlot.controller.runJavaScriptReturningResult(
|
||||||
|
'''
|
||||||
|
(() => {
|
||||||
|
try {
|
||||||
|
return JSON.stringify({
|
||||||
|
marker: 'openim-h5-runtime-alive',
|
||||||
|
readyState: document.readyState,
|
||||||
|
href: window.location.href,
|
||||||
|
now: Date.now()
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
return 'openim-h5-runtime-error';
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
''',
|
||||||
|
).timeout(const Duration(seconds: 2));
|
||||||
|
return result.toString().contains('openim-h5-runtime-alive');
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Uri> _currentReloadUri({required bool cacheBust}) async {
|
||||||
|
String? currentUrl;
|
||||||
|
try {
|
||||||
|
currentUrl = await _lineSlot.controller.currentUrl();
|
||||||
|
} catch (_) {
|
||||||
|
currentUrl = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final parsedCurrentUrl =
|
||||||
|
currentUrl == null ? null : Uri.tryParse(currentUrl);
|
||||||
|
final uri = parsedCurrentUrl?.hasScheme == true
|
||||||
|
? parsedCurrentUrl!
|
||||||
|
: Uri.parse(_lineSlot.line.url);
|
||||||
|
|
||||||
|
if (!cacheBust) {
|
||||||
|
return uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
final queryParameters = Map<String, String>.from(uri.queryParameters)
|
||||||
|
..['_shell_resume_ts'] = DateTime.now().millisecondsSinceEpoch.toString();
|
||||||
|
return uri.replace(queryParameters: queryParameters);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _recoverWebViewAfterResume(Duration backgroundDuration) async {
|
||||||
|
final token = ++_resumeRecoveryToken;
|
||||||
|
final generation = _lineGeneration;
|
||||||
|
await _syncShellRequestUrl();
|
||||||
|
await _syncShellBranding();
|
||||||
|
await _syncKeyboardState();
|
||||||
|
await _notifyH5AppResumed(backgroundDuration);
|
||||||
|
|
||||||
|
if (!_isCurrentLineGeneration(generation) ||
|
||||||
|
token != _resumeRecoveryToken ||
|
||||||
|
backgroundDuration < _longBackgroundRefreshThreshold) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final responsive = await _isH5RuntimeResponsive();
|
||||||
|
if (!mounted ||
|
||||||
|
!_isCurrentLineGeneration(generation) ||
|
||||||
|
token != _resumeRecoveryToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logH5LineDebug(
|
||||||
|
'后台 ${backgroundDuration.inMinutes} 分钟后恢复,'
|
||||||
|
'${responsive ? '刷新 WebView 以拉取最新 H5' : 'WebView JS 无响应,重载当前页'}',
|
||||||
|
);
|
||||||
|
await _reloadCurrentLine(forceRefresh: true);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _handleShellMediaPermissionRequest({
|
Future<void> _handleShellMediaPermissionRequest({
|
||||||
required int generation,
|
required int generation,
|
||||||
required String requestId,
|
required String requestId,
|
||||||
@@ -907,7 +848,6 @@ class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver {
|
|||||||
|
|
||||||
_isReplacingUnavailableLine = true;
|
_isReplacingUnavailableLine = true;
|
||||||
try {
|
try {
|
||||||
await _clearAllH5Caches();
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isPreparingInitialLine = true;
|
_isPreparingInitialLine = true;
|
||||||
@@ -943,7 +883,7 @@ class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver {
|
|||||||
await _syncKeyboardState();
|
await _syncKeyboardState();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _reloadCurrentLine() async {
|
Future<void> _reloadCurrentLine({bool forceRefresh = false}) async {
|
||||||
final slot = _currentSlot;
|
final slot = _currentSlot;
|
||||||
if (slot.availability == _LineAvailability.unavailable ||
|
if (slot.availability == _LineAvailability.unavailable ||
|
||||||
slot.loadError != null) {
|
slot.loadError != null) {
|
||||||
@@ -961,7 +901,11 @@ class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (slot.hasLoadedInitialRequest) {
|
if (forceRefresh) {
|
||||||
|
await slot.controller.loadRequest(
|
||||||
|
await _currentReloadUri(cacheBust: true),
|
||||||
|
);
|
||||||
|
} else if (slot.hasLoadedInitialRequest) {
|
||||||
await slot.controller.reload();
|
await slot.controller.reload();
|
||||||
} else {
|
} else {
|
||||||
await _loadCurrentLine();
|
await _loadCurrentLine();
|
||||||
@@ -972,6 +916,7 @@ class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver {
|
|||||||
PlatformWebViewWidgetCreationParams params =
|
PlatformWebViewWidgetCreationParams params =
|
||||||
PlatformWebViewWidgetCreationParams(
|
PlatformWebViewWidgetCreationParams(
|
||||||
controller: _lineSlot.controller.platform,
|
controller: _lineSlot.controller.platform,
|
||||||
|
gestureRecognizers: _webViewGestureRecognizers,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (_lineSlot.controller.platform is AndroidWebViewController) {
|
if (_lineSlot.controller.platform is AndroidWebViewController) {
|
||||||
@@ -998,18 +943,52 @@ class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver {
|
|||||||
return NavigationDecision.prevent;
|
return NavigationDecision.prevent;
|
||||||
}
|
}
|
||||||
|
|
||||||
const webSchemes = {'http', 'https', 'about', 'data'};
|
const inWebViewSchemes = {'about', 'data'};
|
||||||
if (webSchemes.contains(uri.scheme)) {
|
if (inWebViewSchemes.contains(uri.scheme) || _isCurrentH5Origin(uri)) {
|
||||||
return NavigationDecision.navigate;
|
return NavigationDecision.navigate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (uri.scheme == 'http' || uri.scheme == 'https') {
|
||||||
|
await _openExternalUri(uri);
|
||||||
|
return NavigationDecision.prevent;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _openExternalUri(uri);
|
||||||
|
return NavigationDecision.prevent;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isCurrentH5Origin(Uri uri) {
|
||||||
|
final currentUri = Uri.tryParse(_currentSlot.line.url);
|
||||||
|
if (currentUri == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return uri.scheme == currentUri.scheme &&
|
||||||
|
uri.host == currentUri.host &&
|
||||||
|
uri.port == currentUri.port;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openExternalUrl(String? rawUrl) async {
|
||||||
|
final url = rawUrl?.trim();
|
||||||
|
if (url == null || url.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final uri = Uri.tryParse(url);
|
||||||
|
if (uri == null || !uri.hasScheme) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _openExternalUri(uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openExternalUri(Uri uri) async {
|
||||||
try {
|
try {
|
||||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Ignore unsupported custom schemes so the WebView does not navigate to
|
// Ignore unsupported custom schemes so the WebView does not navigate to
|
||||||
// an error page.
|
// an error page.
|
||||||
}
|
}
|
||||||
return NavigationDecision.prevent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleBackNavigation() async {
|
Future<void> _handleBackNavigation() async {
|
||||||
@@ -1030,6 +1009,12 @@ class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver {
|
|||||||
final shouldPaintShellFallback =
|
final shouldPaintShellFallback =
|
||||||
(_isPreparingInitialLine || currentSlot.isAwaitingFirstScreen) &&
|
(_isPreparingInitialLine || currentSlot.isAwaitingFirstScreen) &&
|
||||||
currentSlot.loadError == null;
|
currentSlot.loadError == null;
|
||||||
|
final shouldPaintShellCover =
|
||||||
|
currentSlot.showShellCover && !currentSlot.hasPresentedFirstScreen;
|
||||||
|
final shouldPaintWebView = currentSlot.hasLoadedInitialRequest ||
|
||||||
|
currentSlot.hasPresentedFirstScreen;
|
||||||
|
final shellBackgroundColor =
|
||||||
|
shouldPaintShellFallback ? _shellBackground : Colors.white;
|
||||||
|
|
||||||
return PopScope(
|
return PopScope(
|
||||||
canPop: false,
|
canPop: false,
|
||||||
@@ -1039,22 +1024,20 @@ class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: shellBackgroundColor,
|
||||||
body: Stack(
|
body: Stack(
|
||||||
children: [
|
children: [
|
||||||
SafeArea(
|
SafeArea(
|
||||||
bottom: false,
|
bottom: false,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
if (shouldPaintShellFallback)
|
if (shouldPaintShellFallback && !shouldPaintShellCover)
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: _ShellFallback(progress: currentSlot.progress),
|
child: _ShellFallback(progress: currentSlot.progress),
|
||||||
),
|
),
|
||||||
Positioned.fill(
|
if (shouldPaintWebView)
|
||||||
child: _buildWebViewWidget(),
|
Positioned.fill(child: _buildWebViewWidget()),
|
||||||
),
|
if (shouldPaintShellCover)
|
||||||
if (currentSlot.showShellCover &&
|
|
||||||
!currentSlot.hasPresentedFirstScreen)
|
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: IgnorePointer(
|
child: IgnorePointer(
|
||||||
child: _ShellFallback(progress: currentSlot.progress),
|
child: _ShellFallback(progress: currentSlot.progress),
|
||||||
@@ -1083,8 +1066,7 @@ class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver {
|
|||||||
right: 0,
|
right: 0,
|
||||||
height: topInset,
|
height: topInset,
|
||||||
child: ColoredBox(
|
child: ColoredBox(
|
||||||
color:
|
color: shellBackgroundColor,
|
||||||
shouldPaintShellFallback ? _shellBackground : Colors.white,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -1107,10 +1089,7 @@ class _NativeMediaPermissionResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _H5LineWebViewSlot {
|
class _H5LineWebViewSlot {
|
||||||
_H5LineWebViewSlot({
|
_H5LineWebViewSlot({required this.line, required this.controller});
|
||||||
required this.line,
|
|
||||||
required this.controller,
|
|
||||||
});
|
|
||||||
|
|
||||||
final H5Line line;
|
final H5Line line;
|
||||||
final WebViewController controller;
|
final WebViewController controller;
|
||||||
@@ -1159,10 +1138,7 @@ class _ErrorPanel extends StatelessWidget {
|
|||||||
style: Theme.of(context).textTheme.bodyMedium,
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
FilledButton(
|
FilledButton(onPressed: onRetry, child: const Text('重新加载')),
|
||||||
onPressed: onRetry,
|
|
||||||
child: const Text('重新加载'),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1188,8 +1164,10 @@ class _ShellFallback extends StatelessWidget {
|
|||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final markSize = (constraints.maxWidth * 0.34).clamp(116.0, 148.0);
|
final markSize = (constraints.maxWidth * 0.34).clamp(116.0, 148.0);
|
||||||
final contentTop = constraints.maxHeight * 0.28;
|
final contentTop = constraints.maxHeight * 0.28;
|
||||||
final progressWidth =
|
final progressWidth = (constraints.maxWidth * 0.32).clamp(
|
||||||
(constraints.maxWidth * 0.32).clamp(116.0, 152.0);
|
116.0,
|
||||||
|
152.0,
|
||||||
|
);
|
||||||
|
|
||||||
return Align(
|
return Align(
|
||||||
alignment: Alignment.topCenter,
|
alignment: Alignment.topCenter,
|
||||||
@@ -1318,11 +1296,7 @@ class _H5LoadingBackgroundPainter extends CustomPainter {
|
|||||||
..shader = const LinearGradient(
|
..shader = const LinearGradient(
|
||||||
begin: Alignment.topCenter,
|
begin: Alignment.topCenter,
|
||||||
end: Alignment.bottomCenter,
|
end: Alignment.bottomCenter,
|
||||||
colors: [
|
colors: [Color(0xFFEAF7FF), Color(0xFFF8FCFF), Colors.white],
|
||||||
Color(0xFFEAF7FF),
|
|
||||||
Color(0xFFF8FCFF),
|
|
||||||
Colors.white,
|
|
||||||
],
|
|
||||||
stops: [0, 0.52, 1],
|
stops: [0, 0.52, 1],
|
||||||
).createShader(pageRect);
|
).createShader(pageRect);
|
||||||
canvas.drawRect(pageRect, pagePaint);
|
canvas.drawRect(pageRect, pagePaint);
|
||||||
@@ -1471,10 +1445,7 @@ class _BubbleCheckPainter extends CustomPainter {
|
|||||||
..shader = const LinearGradient(
|
..shader = const LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: [
|
colors: [Color(0xFFE8FBF8), Color(0xFFAEECE4)],
|
||||||
Color(0xFFE8FBF8),
|
|
||||||
Color(0xFFAEECE4),
|
|
||||||
],
|
|
||||||
).createShader(backBubble.outerRect);
|
).createShader(backBubble.outerRect);
|
||||||
canvas.drawRRect(backBubble, backBubblePaint);
|
canvas.drawRRect(backBubble, backBubblePaint);
|
||||||
|
|
||||||
@@ -1499,11 +1470,7 @@ class _BubbleCheckPainter extends CustomPainter {
|
|||||||
..shader = const LinearGradient(
|
..shader = const LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: [
|
colors: [Color(0xFFA8DDFF), _shellAccent, _shellAccentDeep],
|
||||||
Color(0xFFA8DDFF),
|
|
||||||
_shellAccent,
|
|
||||||
_shellAccentDeep,
|
|
||||||
],
|
|
||||||
stops: [0, 0.48, 1],
|
stops: [0, 0.48, 1],
|
||||||
).createShader(frontRect);
|
).createShader(frontRect);
|
||||||
canvas.drawPath(frontTail, bubblePaint);
|
canvas.drawPath(frontTail, bubblePaint);
|
||||||
|
|||||||
Reference in New Issue
Block a user