Commit 84656a6ee30c59e452acbd11d6e90b6a19295f77

Authored by xiaoyu
2 parents 618750f4 6770c637

Merge remote-tracking branch 'origin/fix-build-wqf'

README.md
1 # Wow English 1 # Wow English
2 2
3 * flutter版本号:3.19.2 3 * flutter版本号:3.19.2
  4 +
  5 +## Android 使用 Whistle 抓包
  6 +
  7 +为了让 Dio 的 HTTPS 请求能够经过 Whistle 解密,Android Debug 包默认允许任意服务器证书,不需要在项目中内置或指定 Whistle Root CA。普通 Release 包仍使用系统默认的严格证书校验。
  8 +
  9 +### 构建方式
  10 +
  11 +Debug 包默认允许 Whistle 抓包:
  12 +
  13 +```bash
  14 +flutter build apk --debug
  15 +```
  16 +
  17 +构建允许 Whistle 抓包的 Release 包:
  18 +
  19 +```bash
  20 +flutter build apk --release --dart-define=ENABLE_WHISTLE_CA=true
  21 +```
  22 +
  23 +构建正常生产 Release 包(不允许 Whistle 抓包):
  24 +
  25 +```bash
  26 +flutter build apk --release
  27 +```
  28 +
  29 +手机通过 Clash Meta 将流量转发到运行 Whistle 的电脑后,即可抓取 Dio 发出的 HTTPS 请求。因为抓包构建不绑定具体 CA,所以更换 Whistle 实例或重新生成 Root CA 后无需修改代码和重新导入证书。
  30 +
  31 +> Debug 包以及带 `ENABLE_WHISTLE_CA=true` 的 Release 包会接受任意 HTTPS 证书,无法抵御中间人攻击。此类 APK 只应在内部测试设备和测试人员之间分发,不能作为正式生产包发布。
android/app/build.gradle
@@ -109,5 +109,5 @@ dependencies { @@ -109,5 +109,5 @@ dependencies {
109 // kotlin扩展(可选) 109 // kotlin扩展(可选)
110 implementation 'com.geyifeng.immersionbar:immersionbar-ktx:3.2.2' 110 implementation 'com.geyifeng.immersionbar:immersionbar-ktx:3.2.2'
111 // coco2d游戏 111 // coco2d游戏
112 - implementation 'io.keyss.android.library:steve_game:1.0.1' 112 + implementation 'com.ishow.android:steve_game:1.0.1'
113 } 113 }
android/build.gradle
  1 +apply from: "/Users/stay/StudioProjects/ishowMavenRepositories.gradle"
  2 +
1 buildscript { 3 buildscript {
2 ext.kotlin_version = '1.8.21' 4 ext.kotlin_version = '1.8.21'
3 repositories { 5 repositories {
@@ -29,6 +31,8 @@ allprojects { @@ -29,6 +31,8 @@ allprojects {
29 } 31 }
30 } 32 }
31 33
  34 +project.ext.setIshowMavenRepositories(project)
  35 +
32 rootProject.buildDir = '../build' 36 rootProject.buildDir = '../build'
33 subprojects { 37 subprojects {
34 project.buildDir = "${rootProject.buildDir}/${project.name}" 38 project.buildDir = "${rootProject.buildDir}/${project.name}"
assets/images/ic_customer_service.png 0 → 100644

102 KB

lib/common/request/request_client.dart
1 import 'dart:convert'; 1 import 'dart:convert';
  2 +import 'dart:io';
2 3
3 import 'package:dio/dio.dart'; 4 import 'package:dio/dio.dart';
  5 +import 'package:dio/io.dart';
4 import 'package:flutter/foundation.dart'; 6 import 'package:flutter/foundation.dart';
5 import 'package:pretty_dio_logger/pretty_dio_logger.dart'; 7 import 'package:pretty_dio_logger/pretty_dio_logger.dart';
6 import 'package:wow_english/utils/toast_util.dart'; 8 import 'package:wow_english/utils/toast_util.dart';
@@ -17,14 +19,32 @@ part 'apis.dart'; @@ -17,14 +19,32 @@ part 'apis.dart';
17 RequestClient requestClient = RequestClient(); 19 RequestClient requestClient = RequestClient();
18 20
19 class RequestClient { 21 class RequestClient {
  22 + static const bool _enableWhistleCaptureInRelease = bool.fromEnvironment(
  23 + 'ENABLE_WHISTLE_CA',
  24 + defaultValue: false,
  25 + );
  26 +
20 late Dio _dio; 27 late Dio _dio;
21 28
22 RequestClient() { 29 RequestClient() {
23 - _dio = Dio(BaseOptions(baseUrl: RequestConfig.baseUrl, connectTimeout: RequestConfig.connectTimeout)); 30 + _dio = Dio(BaseOptions(
  31 + baseUrl: RequestConfig.baseUrl,
  32 + connectTimeout: RequestConfig.connectTimeout));
24 _dio.interceptors.add(TokenInterceptor()); 33 _dio.interceptors.add(TokenInterceptor());
25 if (kDebugMode) { 34 if (kDebugMode) {
26 - _dio.interceptors  
27 - .add(PrettyDioLogger(requestHeader: true, requestBody: true, responseHeader: true, maxWidth: 120)); 35 + _dio.interceptors.add(PrettyDioLogger(
  36 + requestHeader: true,
  37 + requestBody: true,
  38 + responseHeader: true,
  39 + maxWidth: 120));
  40 + }
  41 +
  42 + // 仅供内部抓包构建使用;启用后会接受任意 HTTPS 证书。
  43 + if (Platform.isAndroid && (kDebugMode || _enableWhistleCaptureInRelease)) {
  44 + _dio.httpClientAdapter = IOHttpClientAdapter(
  45 + createHttpClient: () =>
  46 + HttpClient()..badCertificateCallback = (_, __, ___) => true,
  47 + );
28 } 48 }
29 } 49 }
30 50
@@ -79,7 +99,11 @@ class RequestClient { @@ -79,7 +99,11 @@ class RequestClient {
79 bool Function(ApiException)? onError, 99 bool Function(ApiException)? onError,
80 }) { 100 }) {
81 return request(url, 101 return request(url,
82 - method: 'GET', queryParameters: queryParameters, headers: headers, onResponse: onResponse, onError: onError); 102 + method: 'GET',
  103 + queryParameters: queryParameters,
  104 + headers: headers,
  105 + onResponse: onResponse,
  106 + onError: onError);
83 } 107 }
84 108
85 /// post 109 /// post
lib/generated/json/app_config_entity.g.dart
@@ -7,20 +7,37 @@ AppConfigEntity $AppConfigEntityFromJson(Map<String, dynamic> json) { @@ -7,20 +7,37 @@ AppConfigEntity $AppConfigEntityFromJson(Map<String, dynamic> json) {
7 if (safe != null) { 7 if (safe != null) {
8 appConfigEntity.safe = safe; 8 appConfigEntity.safe = safe;
9 } 9 }
  10 + final List<String>? customerMobileList =
  11 + (json['customerMobileList'] as List<dynamic>?)
  12 + ?.map((e) => jsonConvert.convert<String>(e) as String)
  13 + .toList();
  14 + if (customerMobileList != null) {
  15 + appConfigEntity.customerMobileList = customerMobileList;
  16 + }
  17 + final String? customerQr = jsonConvert.convert<String>(json['customerQr']);
  18 + if (customerQr != null) {
  19 + appConfigEntity.customerQr = customerQr;
  20 + }
10 return appConfigEntity; 21 return appConfigEntity;
11 } 22 }
12 23
13 Map<String, dynamic> $AppConfigEntityToJson(AppConfigEntity entity) { 24 Map<String, dynamic> $AppConfigEntityToJson(AppConfigEntity entity) {
14 final Map<String, dynamic> data = <String, dynamic>{}; 25 final Map<String, dynamic> data = <String, dynamic>{};
15 data['safe'] = entity.safe; 26 data['safe'] = entity.safe;
  27 + data['customerMobileList'] = entity.customerMobileList;
  28 + data['customerQr'] = entity.customerQr;
16 return data; 29 return data;
17 } 30 }
18 31
19 extension AppConfigEntityExtension on AppConfigEntity { 32 extension AppConfigEntityExtension on AppConfigEntity {
20 AppConfigEntity copyWith({ 33 AppConfigEntity copyWith({
21 String? safe, 34 String? safe,
  35 + List<String>? customerMobileList,
  36 + String? customerQr,
22 }) { 37 }) {
23 return AppConfigEntity() 38 return AppConfigEntity()
24 - ..safe = safe ?? this.safe; 39 + ..safe = safe ?? this.safe
  40 + ..customerMobileList = customerMobileList ?? this.customerMobileList
  41 + ..customerQr = customerQr ?? this.customerQr;
25 } 42 }
26 } 43 }
27 \ No newline at end of file 44 \ No newline at end of file
lib/models/app_config_entity.dart
@@ -6,24 +6,29 @@ import &#39;../generated/json/app_config_entity.g.dart&#39;; @@ -6,24 +6,29 @@ import &#39;../generated/json/app_config_entity.g.dart&#39;;
6 6
7 @JsonSerializable() 7 @JsonSerializable()
8 class AppConfigEntity { 8 class AppConfigEntity {
  9 + // 当前是否安全,safe-安全 otherwise-隐藏pay
  10 + String? safe;
9 11
10 - // 当前是否安全,safe-安全 otherwise-隐藏pay  
11 - String? safe; 12 + // 客服手机号列表
  13 + List<String> customerMobileList = [];
12 14
  15 + // 客服二维码图片地址
  16 + String? customerQr;
13 17
14 - AppConfigEntity(); 18 + AppConfigEntity();
15 19
16 - factory AppConfigEntity.fromJson(Map<String, dynamic> json) => $AppConfigEntityFromJson(json); 20 + factory AppConfigEntity.fromJson(Map<String, dynamic> json) =>
  21 + $AppConfigEntityFromJson(json);
17 22
18 - Map<String, dynamic> toJson() => $AppConfigEntityToJson(this); 23 + Map<String, dynamic> toJson() => $AppConfigEntityToJson(this);
19 24
20 - @override  
21 - String toString() {  
22 - return jsonEncode(this);  
23 - } 25 + @override
  26 + String toString() {
  27 + return jsonEncode(this);
  28 + }
24 29
25 - // 是否审核中(null或者非"safe"即不安全)  
26 - bool isAppReviewing() {  
27 - return safe != "safe";  
28 - } 30 + // 是否审核中(null或者非"safe"即不安全)
  31 + bool isAppReviewing() {
  32 + return safe != "safe";
  33 + }
29 } 34 }
30 \ No newline at end of file 35 \ No newline at end of file
lib/pages/home/widgets/BaseHomeHeaderWidget.dart
@@ -10,6 +10,7 @@ import &#39;../../../models/course_entity.dart&#39;; @@ -10,6 +10,7 @@ import &#39;../../../models/course_entity.dart&#39;;
10 import '../../../route/route.dart'; 10 import '../../../route/route.dart';
11 import '../../../utils/image_util.dart'; 11 import '../../../utils/image_util.dart';
12 import '../../user/bloc/user_bloc.dart'; 12 import '../../user/bloc/user_bloc.dart';
  13 +import 'customer_service_qr_dialog.dart';
13 14
14 typedef HeaderCallback = void Function(dynamic); 15 typedef HeaderCallback = void Function(dynamic);
15 16
@@ -80,6 +81,28 @@ class BaseHomeHeaderWidget extends StatelessWidget { @@ -80,6 +81,28 @@ class BaseHomeHeaderWidget extends StatelessWidget {
80 textAlign: TextAlign.left, 81 textAlign: TextAlign.left,
81 style: TextStyle(color: Colors.white, fontSize: 30.0), 82 style: TextStyle(color: Colors.white, fontSize: 30.0),
82 )), 83 )),
  84 + FutureBuilder(
  85 + future: AppConfigHelper.getAppConfig(),
  86 + builder: (context, snapshot) {
  87 + final customerQr = snapshot.data?.customerQr?.trim() ?? '';
  88 + if (customerQr.isEmpty) {
  89 + return const SizedBox.shrink();
  90 + }
  91 + return GestureDetector(
  92 + onTap: () =>
  93 + showCustomerServiceQrDialog(context, customerQr),
  94 + child: Padding(
  95 + padding: EdgeInsets.only(right: 12.w),
  96 + child: Image.asset(
  97 + 'ic_customer_service'.assetPng,
  98 + width: 90.w,
  99 + height: 28.h,
  100 + fit: BoxFit.contain,
  101 + ),
  102 + ),
  103 + );
  104 + },
  105 + ),
83 Offstage( 106 Offstage(
84 offstage: AppConfigHelper.shouldHidePay() || 107 offstage: AppConfigHelper.shouldHidePay() ||
85 !UserUtil.isLogined(), 108 !UserUtil.isLogined(),
lib/pages/home/widgets/customer_service_qr_dialog.dart 0 → 100644
  1 +import 'package:flutter/material.dart';
  2 +import 'package:flutter_screenutil/flutter_screenutil.dart';
  3 +
  4 +Future<void> showCustomerServiceQrDialog(
  5 + BuildContext context,
  6 + String imageUrl,
  7 +) {
  8 + return showDialog<void>(
  9 + context: context,
  10 + barrierColor: Colors.black54,
  11 + barrierDismissible: true,
  12 + builder: (context) => Dialog(
  13 + backgroundColor: Colors.transparent,
  14 + insetPadding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 10.h),
  15 + child: ClipRRect(
  16 + borderRadius: BorderRadius.circular(12.r),
  17 + child: SizedBox(
  18 + height: 340.h,
  19 + child: AspectRatio(
  20 + aspectRatio: 870 / 1110,
  21 + child: Image.network(
  22 + imageUrl,
  23 + width: double.infinity,
  24 + height: double.infinity,
  25 + fit: BoxFit.cover,
  26 + loadingBuilder: (context, child, progress) {
  27 + if (progress == null) {
  28 + return child;
  29 + }
  30 + return Container(
  31 + color: Colors.white,
  32 + alignment: Alignment.center,
  33 + child: const CircularProgressIndicator(),
  34 + );
  35 + },
  36 + errorBuilder: (context, error, stackTrace) => Container(
  37 + color: Colors.white,
  38 + alignment: Alignment.center,
  39 + child: Text(
  40 + '图片加载失败',
  41 + style: TextStyle(
  42 + color: Colors.grey[600],
  43 + fontSize: 14.sp,
  44 + ),
  45 + ),
  46 + ),
  47 + ),
  48 + ),
  49 + ),
  50 + ),
  51 + ),
  52 + );
  53 +}
lib/pages/shop/home/shop_home_page.dart
@@ -5,7 +5,6 @@ import &#39;package:wow_english/common/extension/string_extension.dart&#39;; @@ -5,7 +5,6 @@ import &#39;package:wow_english/common/extension/string_extension.dart&#39;;
5 import 'package:wow_english/common/widgets/we_app_bar.dart'; 5 import 'package:wow_english/common/widgets/we_app_bar.dart';
6 import 'package:wow_english/pages/shop/home/widgets/product_item.dart'; 6 import 'package:wow_english/pages/shop/home/widgets/product_item.dart';
7 import 'package:wow_english/route/route.dart'; 7 import 'package:wow_english/route/route.dart';
8 -import 'package:wow_english/utils/toast_util.dart';  
9 8
10 import 'bloc/shop_home_bloc.dart'; 9 import 'bloc/shop_home_bloc.dart';
11 10
lib/pages/shop/home/widgets/product_item.dart
@@ -67,10 +67,13 @@ class ProductItem extends StatelessWidget { @@ -67,10 +67,13 @@ class ProductItem extends StatelessWidget {
67 ), 67 ),
68 ), 68 ),
69 GestureDetector( 69 GestureDetector(
  70 + behavior: HitTestBehavior.opaque,
70 onTap: () { 71 onTap: () {
71 onTap(); 72 onTap();
72 }, 73 },
73 child: Container( 74 child: Container(
  75 + width: double.infinity,
  76 + constraints: BoxConstraints(minHeight: 28.h),
74 decoration: BoxDecoration( 77 decoration: BoxDecoration(
75 color: const Color(0xFFF5C51F), 78 color: const Color(0xFFF5C51F),
76 borderRadius: BorderRadius.circular(5.r), 79 borderRadius: BorderRadius.circular(5.r),
@@ -78,17 +81,14 @@ class ProductItem extends StatelessWidget { @@ -78,17 +81,14 @@ class ProductItem extends StatelessWidget {
78 color: const Color(0xFF333333), 81 color: const Color(0xFF333333),
79 width: 1.0, 82 width: 1.0,
80 )), 83 )),
81 - padding: EdgeInsets.symmetric(  
82 - vertical: 1.h  
83 - ), 84 + padding: EdgeInsets.symmetric(vertical: 4.h),
84 child: Center( 85 child: Center(
85 child: Text( 86 child: Text(
86 '立即购买', 87 '立即购买',
87 style: TextStyle( 88 style: TextStyle(
88 fontSize: 10.sp, color: const Color(0xFF333333)), 89 fontSize: 10.sp, color: const Color(0xFF333333)),
89 ), 90 ),
90 - )  
91 - ), 91 + )),
92 ) 92 )
93 ], 93 ],
94 ), 94 ),
lib/pages/user/user_page.dart
@@ -13,6 +13,7 @@ import &#39;package:wow_english/models/user_entity.dart&#39;; @@ -13,6 +13,7 @@ import &#39;package:wow_english/models/user_entity.dart&#39;;
13 import 'package:wow_english/pages/user/bloc/user_bloc.dart'; 13 import 'package:wow_english/pages/user/bloc/user_bloc.dart';
14 import 'package:wow_english/route/route.dart'; 14 import 'package:wow_english/route/route.dart';
15 import 'package:wow_english/utils/image_util.dart'; 15 import 'package:wow_english/utils/image_util.dart';
  16 +import 'package:wow_english/utils/toast_util.dart';
16 17
17 class UserPage extends StatelessWidget { 18 class UserPage extends StatelessWidget {
18 const UserPage({super.key}); 19 const UserPage({super.key});
@@ -196,6 +197,16 @@ class _UserView extends StatelessWidget { @@ -196,6 +197,16 @@ class _UserView extends StatelessWidget {
196 child: 12.verticalSpace, 197 child: 12.verticalSpace,
197 ), 198 ),
198 OutlinedButton( 199 OutlinedButton(
  200 + onPressed: () {
  201 + _showCustomerServiceDialog(context);
  202 + },
  203 + style: normalButtonStyle,
  204 + child: Text(
  205 + "联系客服",
  206 + style: textStyle21sp,
  207 + )),
  208 + 12.verticalSpace,
  209 + OutlinedButton(
199 onPressed: () => pushNamed(AppRouteName.fogPwd), 210 onPressed: () => pushNamed(AppRouteName.fogPwd),
200 style: normalButtonStyle, 211 style: normalButtonStyle,
201 child: Text( 212 child: Text(
@@ -232,16 +243,6 @@ class _UserView extends StatelessWidget { @@ -232,16 +243,6 @@ class _UserView extends StatelessWidget {
232 12.verticalSpace, 243 12.verticalSpace,
233 OutlinedButton( 244 OutlinedButton(
234 onPressed: () { 245 onPressed: () {
235 - _showTeacherSelectionDialog(context);  
236 - },  
237 - style: normalButtonStyle,  
238 - child: Text(  
239 - "联系客服",  
240 - style: textStyle21sp,  
241 - )),  
242 - 12.verticalSpace,  
243 - OutlinedButton(  
244 - onPressed: () {  
245 pushNamed(AppRouteName.setting); 246 pushNamed(AppRouteName.setting);
246 }, 247 },
247 style: normalButtonStyle, 248 style: normalButtonStyle,
@@ -308,7 +309,33 @@ class _UserView extends StatelessWidget { @@ -308,7 +309,33 @@ class _UserView extends StatelessWidget {
308 }, 309 },
309 ); 310 );
310 311
311 - void _showTeacherSelectionDialog(BuildContext context) { 312 + Future<void> _showCustomerServiceDialog(BuildContext context) async {
  313 + try {
  314 + final config = await AppConfigHelper.getAppConfig();
  315 + if (!context.mounted) {
  316 + return;
  317 + }
  318 +
  319 + final mobileList = config?.customerMobileList
  320 + .map((mobile) => mobile.trim())
  321 + .where((mobile) => mobile.isNotEmpty)
  322 + .toSet()
  323 + .toList() ??
  324 + [];
  325 + if (mobileList.isEmpty) {
  326 + showToast('暂无客服联系方式');
  327 + return;
  328 + }
  329 + _showMobileSelectionDialog(context, mobileList);
  330 + } catch (_) {
  331 + if (context.mounted) {
  332 + showToast('客服联系方式获取失败,请稍后重试');
  333 + }
  334 + }
  335 + }
  336 +
  337 + void _showMobileSelectionDialog(
  338 + BuildContext context, List<String> mobileList) {
312 showModalBottomSheet( 339 showModalBottomSheet(
313 context: context, 340 context: context,
314 backgroundColor: Colors.white, 341 backgroundColor: Colors.white,
@@ -333,30 +360,28 @@ class _UserView extends StatelessWidget { @@ -333,30 +360,28 @@ class _UserView extends StatelessWidget {
333 borderRadius: BorderRadius.circular(2), 360 borderRadius: BorderRadius.circular(2),
334 ), 361 ),
335 ), 362 ),
336 - // 老师选项列表 363 + // 客服手机号列表
337 Flexible( 364 Flexible(
338 child: SingleChildScrollView( 365 child: SingleChildScrollView(
339 child: Column( 366 child: Column(
340 mainAxisSize: MainAxisSize.min, 367 mainAxisSize: MainAxisSize.min,
341 - children: [  
342 - // 恐龙老师选项  
343 - ListTile(  
344 - contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), 368 + children:
  369 + List.generate(mobileList.length * 2 - 1, (index) {
  370 + if (index.isOdd) {
  371 + return const Divider(height: 1);
  372 + }
  373 + final mobile = mobileList[index ~/ 2];
  374 + return ListTile(
  375 + contentPadding: const EdgeInsets.symmetric(
  376 + horizontal: 8, vertical: 4),
345 title: Text( 377 title: Text(
346 - '恐龙老师', 378 + mobile,
347 style: TextStyle( 379 style: TextStyle(
348 fontSize: 14.sp, 380 fontSize: 14.sp,
349 fontWeight: FontWeight.w600, 381 fontWeight: FontWeight.w600,
350 color: Colors.black87, 382 color: Colors.black87,
351 ), 383 ),
352 ), 384 ),
353 - subtitle: Text(  
354 - '19357119913',  
355 - style: TextStyle(  
356 - fontSize: 12.sp,  
357 - color: Colors.grey[600],  
358 - ),  
359 - ),  
360 trailing: Icon( 385 trailing: Icon(
361 Icons.phone, 386 Icons.phone,
362 color: Colors.green[600], 387 color: Colors.green[600],
@@ -364,39 +389,10 @@ class _UserView extends StatelessWidget { @@ -364,39 +389,10 @@ class _UserView extends StatelessWidget {
364 ), 389 ),
365 onTap: () { 390 onTap: () {
366 Navigator.pop(context); 391 Navigator.pop(context);
367 - _launchPhone('tel:+8619357119913'); 392 + _launchPhone(mobile);
368 }, 393 },
369 - ),  
370 - const Divider(height: 1),  
371 - // Rose老师选项  
372 - ListTile(  
373 - contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),  
374 - title: Text(  
375 - 'Rose老师',  
376 - style: TextStyle(  
377 - fontSize: 14.sp,  
378 - fontWeight: FontWeight.w600,  
379 - color: Colors.black87,  
380 - ),  
381 - ),  
382 - subtitle: Text(  
383 - '19033986279',  
384 - style: TextStyle(  
385 - fontSize: 12.sp,  
386 - color: Colors.grey[600],  
387 - ),  
388 - ),  
389 - trailing: Icon(  
390 - Icons.phone,  
391 - color: Colors.green[600],  
392 - size: 18,  
393 - ),  
394 - onTap: () {  
395 - Navigator.pop(context);  
396 - _launchPhone('tel:+8619033986279');  
397 - },  
398 - ),  
399 - ], 394 + );
  395 + }),
400 ), 396 ),
401 ), 397 ),
402 ), 398 ),
@@ -433,8 +429,9 @@ class _UserView extends StatelessWidget { @@ -433,8 +429,9 @@ class _UserView extends StatelessWidget {
433 } 429 }
434 430
435 void _launchPhone(String phone) async { 431 void _launchPhone(String phone) async {
436 - if (await canLaunchUrl(Uri.parse(phone))) {  
437 - await launchUrl(Uri.parse(phone)); 432 + final phoneUri = Uri(scheme: 'tel', path: phone);
  433 + if (await canLaunchUrl(phoneUri)) {
  434 + await launchUrl(phoneUri);
438 } else { 435 } else {
439 throw 'Could not phone $phone'; 436 throw 'Could not phone $phone';
440 } 437 }
pubspec.yaml
@@ -16,7 +16,7 @@ publish_to: &#39;none&#39; # Remove this line if you wish to publish to pub.dev @@ -16,7 +16,7 @@ publish_to: &#39;none&#39; # Remove this line if you wish to publish to pub.dev
16 # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html 16 # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
17 # In Windows, build-name is used as the major, minor, and patch parts 17 # In Windows, build-name is used as the major, minor, and patch parts
18 # of the product and file versions while build-number is used as the build suffix. 18 # of the product and file versions while build-number is used as the build suffix.
19 -version: 1.0.11+11 19 +version: 1.0.13+13
20 20
21 environment: 21 environment:
22 sdk: '>=3.2.0 <4.0.0' 22 sdk: '>=3.2.0 <4.0.0'