Showing
13 changed files
with
574 additions
and
396 deletions
| 1 | +import 'dart:async'; | ||
| 2 | + | ||
| 1 | import 'package:doublefeel_flutter/app/modules/login/views/email_login_contact_us_bottom_sheet.dart'; | 3 | import 'package:doublefeel_flutter/app/modules/login/views/email_login_contact_us_bottom_sheet.dart'; |
| 4 | +import 'package:doublefeel_flutter/app/modules/membership_offer/widgets/cancel_subscription_dialog.dart'; | ||
| 5 | +import 'package:doublefeel_flutter/app/utils/platform_compact.dart'; | ||
| 6 | +import 'package:doublefeel_flutter/core/error/app_error.dart'; | ||
| 7 | +import 'package:doublefeel_flutter/core/error/http_error_handling_policy.dart'; | ||
| 2 | import 'package:doublefeel_flutter/core/logging/app_logger.dart'; | 8 | import 'package:doublefeel_flutter/core/logging/app_logger.dart'; |
| 3 | import 'package:doublefeel_flutter/core/network/api/pay_api.dart'; | 9 | import 'package:doublefeel_flutter/core/network/api/pay_api.dart'; |
| 4 | import 'package:doublefeel_flutter/core/network/api/vip_api.dart'; | 10 | import 'package:doublefeel_flutter/core/network/api/vip_api.dart'; |
| 11 | +import 'package:doublefeel_flutter/core/platform/pigeon_api_facade.dart'; | ||
| 5 | import 'package:doublefeel_flutter/core/result/app_result.dart'; | 12 | import 'package:doublefeel_flutter/core/result/app_result.dart'; |
| 6 | import 'package:doublefeel_flutter/core/util/app_toast.dart'; | 13 | import 'package:doublefeel_flutter/core/util/app_toast.dart'; |
| 7 | import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart'; | 14 | import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart'; |
| 8 | import 'package:doublefeel_flutter/data/models/local/user_preferences.dart'; | 15 | import 'package:doublefeel_flutter/data/models/local/user_preferences.dart'; |
| 9 | -import 'package:doublefeel_flutter/data/models/pay/apple_pay_order_response.dart'; | ||
| 10 | -import 'package:doublefeel_flutter/data/models/pay/pay_models.dart'; | ||
| 11 | import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; | 16 | import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; |
| 12 | -import 'package:doublefeel_flutter/core/platform/pigeon_api_facade.dart'; | ||
| 13 | import 'package:flutter/material.dart'; | 17 | import 'package:flutter/material.dart'; |
| 14 | import 'package:get/get.dart'; | 18 | import 'package:get/get.dart'; |
| 15 | 19 | ||
| @@ -27,8 +31,10 @@ class MembershipDetailController extends GetxController { | @@ -27,8 +31,10 @@ class MembershipDetailController extends GetxController { | ||
| 27 | 31 | ||
| 28 | final isRestoring = false.obs; | 32 | final isRestoring = false.obs; |
| 29 | final scrollController = ScrollController(); | 33 | final scrollController = ScrollController(); |
| 34 | + final subscriptionAgreementNo = RxnString(); | ||
| 30 | 35 | ||
| 31 | final isUnlocking = false.obs; | 36 | final isUnlocking = false.obs; |
| 37 | + | ||
| 32 | Future<void> unlock() async { | 38 | Future<void> unlock() async { |
| 33 | // isUnlocking.value = true; | 39 | // isUnlocking.value = true; |
| 34 | // try { | 40 | // try { |
| @@ -139,7 +145,10 @@ class MembershipDetailController extends GetxController { | @@ -139,7 +145,10 @@ class MembershipDetailController extends GetxController { | ||
| 139 | @override | 145 | @override |
| 140 | void onInit() { | 146 | void onInit() { |
| 141 | super.onInit(); | 147 | super.onInit(); |
| 142 | - _refreshVipInfo(); | 148 | + if (isOhos()) { |
| 149 | + unawaited(_loadSubscription()); | ||
| 150 | + } | ||
| 151 | + unawaited(_refreshVipInfo()); | ||
| 143 | } | 152 | } |
| 144 | 153 | ||
| 145 | @override | 154 | @override |
| @@ -180,6 +189,56 @@ class MembershipDetailController extends GetxController { | @@ -180,6 +189,56 @@ class MembershipDetailController extends GetxController { | ||
| 180 | feedbackCompat(pageName: 'Subscription page'); | 189 | feedbackCompat(pageName: 'Subscription page'); |
| 181 | } | 190 | } |
| 182 | 191 | ||
| 192 | + void openSupportEmail() { | ||
| 193 | + final userId = _userPreferences.preferences.value.meUserInfo?.id ?? 0; | ||
| 194 | + sendFeedbackEmail( | ||
| 195 | + userId: userId.toString(), | ||
| 196 | + pageName: 'Subscription page', | ||
| 197 | + ); | ||
| 198 | + } | ||
| 199 | + | ||
| 200 | + void showCancelSubscriptionDialog() { | ||
| 201 | + if (subscriptionAgreementNo.value == null) return; | ||
| 202 | + Get.dialog<void>( | ||
| 203 | + CancelSubscriptionDialog(onConfirm: cancelSubscription), | ||
| 204 | + barrierDismissible: true, | ||
| 205 | + barrierColor: Colors.black.withValues(alpha: 0.7), | ||
| 206 | + ); | ||
| 207 | + } | ||
| 208 | + | ||
| 209 | + Future<void> _loadSubscription() async { | ||
| 210 | + final result = await _payApi.getSubscriptionList(); | ||
| 211 | + if (result case AppSuccess(data: final response)) { | ||
| 212 | + final agreementNo = | ||
| 213 | + response.productList?.firstOrNull?.agreementNo?.trim(); | ||
| 214 | + subscriptionAgreementNo.value = | ||
| 215 | + agreementNo == null || agreementNo.isEmpty ? null : agreementNo; | ||
| 216 | + } | ||
| 217 | + } | ||
| 218 | + | ||
| 219 | + Future<bool> cancelSubscription() async { | ||
| 220 | + final agreementNo = subscriptionAgreementNo.value; | ||
| 221 | + if (agreementNo == null || agreementNo.isEmpty) return false; | ||
| 222 | + | ||
| 223 | + final result = await _payApi.cancelSubscription( | ||
| 224 | + agreementNo, | ||
| 225 | + errorHandlingPolicy: | ||
| 226 | + const HttpErrorHandlingPolicy(showBusinessMessage: false), | ||
| 227 | + ); | ||
| 228 | + if (result case AppSuccess()) { | ||
| 229 | + subscriptionAgreementNo.value = null; | ||
| 230 | + AppToast.show('取消成功'); | ||
| 231 | + return true; | ||
| 232 | + } | ||
| 233 | + if (result case AppFailure(error: final error)) { | ||
| 234 | + final message = error.displayMessage; | ||
| 235 | + if (!error.isAccessTokenInvalid && message.isNotEmpty) { | ||
| 236 | + AppToast.show(message); | ||
| 237 | + } | ||
| 238 | + } | ||
| 239 | + return false; | ||
| 240 | + } | ||
| 241 | + | ||
| 183 | Future<void> manageSubscription() async { | 242 | Future<void> manageSubscription() async { |
| 184 | try { | 243 | try { |
| 185 | await _platformHostApi | 244 | await _platformHostApi |
| 1 | -import 'package:doublefeel_flutter/app/modules/home/views/tabs/my_tab.dart'; | ||
| 2 | import 'package:doublefeel_flutter/app/modules/membership_offer/controllers/membership_detail_controller.dart'; | 1 | import 'package:doublefeel_flutter/app/modules/membership_offer/controllers/membership_detail_controller.dart'; |
| 3 | -import 'package:doublefeel_flutter/app/modules/purchase/widgets/purchase_colors.dart'; | ||
| 4 | import 'package:doublefeel_flutter/app/modules/purchase/widgets/refund_explanation_bottom_sheet.dart'; | 2 | import 'package:doublefeel_flutter/app/modules/purchase/widgets/refund_explanation_bottom_sheet.dart'; |
| 5 | import 'package:doublefeel_flutter/app/utils/platform_compact.dart'; | 3 | import 'package:doublefeel_flutter/app/utils/platform_compact.dart'; |
| 6 | import 'package:doublefeel_flutter/app/widget/premium_benefit_list_view.dart'; | 4 | import 'package:doublefeel_flutter/app/widget/premium_benefit_list_view.dart'; |
| 5 | +import 'package:doublefeel_flutter/app/widget/purchase_legal_notes.dart'; | ||
| 7 | import 'package:doublefeel_flutter/core/theme/app_theme.dart'; | 6 | import 'package:doublefeel_flutter/core/theme/app_theme.dart'; |
| 8 | import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart'; | 7 | import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart'; |
| 9 | import 'package:doublefeel_flutter/data/models/local/user_preferences.dart'; | 8 | import 'package:doublefeel_flutter/data/models/local/user_preferences.dart'; |
| 10 | -import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; | ||
| 11 | import 'package:doublefeel_flutter/l10n/l10n_extensions.dart' as context; | 9 | import 'package:doublefeel_flutter/l10n/l10n_extensions.dart' as context; |
| 12 | -import 'package:flutter/gestures.dart'; | 10 | +import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; |
| 13 | import 'package:flutter/material.dart'; | 11 | import 'package:flutter/material.dart'; |
| 14 | import 'package:get/get.dart'; | 12 | import 'package:get/get.dart'; |
| 15 | import 'package:intl/intl.dart'; | 13 | import 'package:intl/intl.dart'; |
| @@ -250,6 +248,36 @@ class MembershipDetailView extends GetView<MembershipDetailController> { | @@ -250,6 +248,36 @@ class MembershipDetailView extends GetView<MembershipDetailController> { | ||
| 250 | ], | 248 | ], |
| 251 | ), | 249 | ), |
| 252 | ), | 250 | ), |
| 251 | + if (isOhos() && | ||
| 252 | + controller.subscriptionAgreementNo.value != | ||
| 253 | + null) | ||
| 254 | + GestureDetector( | ||
| 255 | + onTap: | ||
| 256 | + controller.showCancelSubscriptionDialog, | ||
| 257 | + behavior: HitTestBehavior.opaque, | ||
| 258 | + child: SizedBox( | ||
| 259 | + height: 56, | ||
| 260 | + child: Row( | ||
| 261 | + children: [ | ||
| 262 | + Text( | ||
| 263 | + '取消自动续费', | ||
| 264 | + textAlign: TextAlign.center, | ||
| 265 | + style: TextStyle( | ||
| 266 | + color: context.colors.textPrimary, | ||
| 267 | + fontSize: 14, | ||
| 268 | + fontWeight: FontWeight.w400, | ||
| 269 | + ), | ||
| 270 | + ), | ||
| 271 | + Spacer(), | ||
| 272 | + Image.asset( | ||
| 273 | + 'assets/images/common/ic_more_gray.png', | ||
| 274 | + width: 16, | ||
| 275 | + height: 16, | ||
| 276 | + ), | ||
| 277 | + ], | ||
| 278 | + ), | ||
| 279 | + ), | ||
| 280 | + ), | ||
| 253 | if (!isOhos() && | 281 | if (!isOhos() && |
| 254 | (vipInfo?.vipType == 1 || | 282 | (vipInfo?.vipType == 1 || |
| 255 | vipInfo?.vipType == 2 || | 283 | vipInfo?.vipType == 2 || |
| @@ -292,7 +320,15 @@ class MembershipDetailView extends GetView<MembershipDetailController> { | @@ -292,7 +320,15 @@ class MembershipDetailView extends GetView<MembershipDetailController> { | ||
| 292 | SizedBox( | 320 | SizedBox( |
| 293 | height: 28, | 321 | height: 28, |
| 294 | ), | 322 | ), |
| 295 | - _LegalNotes(controller: controller) | 323 | + isOhos() |
| 324 | + ? OhosPurchaseLegalNotes( | ||
| 325 | + onContactUsTap: controller.openContactUs, | ||
| 326 | + onSupportEmailTap: controller.openSupportEmail, | ||
| 327 | + ) | ||
| 328 | + : IosPurchaseLegalNotes( | ||
| 329 | + onRefundExplanationTap: showRefundExplanationBottomSheet, | ||
| 330 | + onContactUsTap: controller.openContactUs, | ||
| 331 | + ) | ||
| 296 | ], | 332 | ], |
| 297 | ), | 333 | ), |
| 298 | ), | 334 | ), |
| @@ -334,7 +370,7 @@ class MembershipDetailView extends GetView<MembershipDetailController> { | @@ -334,7 +370,7 @@ class MembershipDetailView extends GetView<MembershipDetailController> { | ||
| 334 | 370 | ||
| 335 | final dateStr = DateFormat.yMMMd(locale).format(date); | 371 | final dateStr = DateFormat.yMMMd(locale).format(date); |
| 336 | 372 | ||
| 337 | - return l10n.membershipValidUntilDate(dateStr); | 373 | + return isOhos() ? dateStr : l10n.membershipValidUntilDate(dateStr); |
| 338 | } | 374 | } |
| 339 | 375 | ||
| 340 | String _vipType(UserPreferencesVipInfo? vipInfo) { | 376 | String _vipType(UserPreferencesVipInfo? vipInfo) { |
| @@ -359,191 +395,3 @@ class MembershipDetailView extends GetView<MembershipDetailController> { | @@ -359,191 +395,3 @@ class MembershipDetailView extends GetView<MembershipDetailController> { | ||
| 359 | } | 395 | } |
| 360 | } | 396 | } |
| 361 | } | 397 | } |
| 362 | - | ||
| 363 | -class _LegalNotes extends StatelessWidget { | ||
| 364 | - const _LegalNotes({required this.controller}); | ||
| 365 | - | ||
| 366 | - final MembershipDetailController controller; | ||
| 367 | - | ||
| 368 | - @override | ||
| 369 | - Widget build(BuildContext context) { | ||
| 370 | - if (isOhos()) { | ||
| 371 | - return Padding( | ||
| 372 | - padding: const EdgeInsets.symmetric(horizontal: 16), | ||
| 373 | - child: Column( | ||
| 374 | - crossAxisAlignment: CrossAxisAlignment.start, | ||
| 375 | - children: [ | ||
| 376 | - Text( | ||
| 377 | - context.l10n.purchaseNotesTitle, | ||
| 378 | - style: const TextStyle( | ||
| 379 | - color: Colors.black, | ||
| 380 | - fontSize: 18, | ||
| 381 | - fontWeight: FontWeight.w600, | ||
| 382 | - height: 1.2, | ||
| 383 | - ), | ||
| 384 | - ), | ||
| 385 | - const SizedBox(height: 24), | ||
| 386 | - const _LegalText( | ||
| 387 | - prefix: '1. ', | ||
| 388 | - text: '确认支付成功后,会员权益将立即生效。', | ||
| 389 | - ), | ||
| 390 | - const SizedBox(height: 12), | ||
| 391 | - const _LegalText( | ||
| 392 | - prefix: '2. ', | ||
| 393 | - text: 'DoubleFeel Pro 会员属于虚拟服务商品,购买后除法律法规另有规定外,通常不支持退款。', | ||
| 394 | - ), | ||
| 395 | - const SizedBox(height: 12), | ||
| 396 | - const _LegalText( | ||
| 397 | - prefix: '3. ', | ||
| 398 | - text: '如支付成功后会员未生效,请尝试重新登录账号。', | ||
| 399 | - ), | ||
| 400 | - const SizedBox(height: 12), | ||
| 401 | - _LegalText( | ||
| 402 | - prefix: '4. ', | ||
| 403 | - text: '如果有更多其它问题请', | ||
| 404 | - linkText: context.l10n.purchaseLinkContactUs, | ||
| 405 | - linkSuffix: '。', | ||
| 406 | - onLinkTap: controller.openContactUs, | ||
| 407 | - ), | ||
| 408 | - ], | ||
| 409 | - ), | ||
| 410 | - ); | ||
| 411 | - } | ||
| 412 | - | ||
| 413 | - return Padding( | ||
| 414 | - padding: const EdgeInsets.symmetric(horizontal: 16), | ||
| 415 | - child: Column( | ||
| 416 | - crossAxisAlignment: CrossAxisAlignment.start, | ||
| 417 | - children: [ | ||
| 418 | - Text( | ||
| 419 | - context.l10n.purchaseNotesTitle, | ||
| 420 | - style: const TextStyle( | ||
| 421 | - color: Colors.black, | ||
| 422 | - fontSize: 18, | ||
| 423 | - fontWeight: FontWeight.w600, | ||
| 424 | - height: 1.2, | ||
| 425 | - ), | ||
| 426 | - ), | ||
| 427 | - const SizedBox(height: 24), | ||
| 428 | - _LegalText( | ||
| 429 | - prefix: '1. ', | ||
| 430 | - text: context.l10n.purchaseNoteSubscription, | ||
| 431 | - linkText: context.l10n.purchaseLinkLearnMore, | ||
| 432 | - linkSuffix: '。', | ||
| 433 | - onLinkTap: showRefundExplanationBottomSheet, | ||
| 434 | - ), | ||
| 435 | - const SizedBox(height: 12), | ||
| 436 | - /* | ||
| 437 | - _LegalText( | ||
| 438 | - prefix: '2. ', | ||
| 439 | - text: context.l10n.purchaseNoteRestore, | ||
| 440 | - linkText: context.l10n.purchaseRestore, | ||
| 441 | - linkSuffix: '。', | ||
| 442 | - onLinkTap: controller.restorePurchase, | ||
| 443 | - ), | ||
| 444 | - const SizedBox(height: 12), | ||
| 445 | - */ | ||
| 446 | - _LegalText( | ||
| 447 | - prefix: '2. ', | ||
| 448 | - text: context.l10n.purchaseNoteContact, | ||
| 449 | - linkText: context.l10n.purchaseLinkContactUs, | ||
| 450 | - linkSuffix: '。', | ||
| 451 | - onLinkTap: controller.openContactUs, | ||
| 452 | - ), | ||
| 453 | - ], | ||
| 454 | - ), | ||
| 455 | - ); | ||
| 456 | - } | ||
| 457 | -} | ||
| 458 | - | ||
| 459 | -class _LegalText extends StatefulWidget { | ||
| 460 | - const _LegalText({ | ||
| 461 | - required this.prefix, | ||
| 462 | - required this.text, | ||
| 463 | - this.linkText, | ||
| 464 | - this.linkSuffix, | ||
| 465 | - this.onLinkTap, | ||
| 466 | - }); | ||
| 467 | - | ||
| 468 | - final String prefix; | ||
| 469 | - final String text; | ||
| 470 | - final String? linkText; | ||
| 471 | - final String? linkSuffix; | ||
| 472 | - final VoidCallback? onLinkTap; | ||
| 473 | - | ||
| 474 | - @override | ||
| 475 | - State<_LegalText> createState() => _LegalTextState(); | ||
| 476 | -} | ||
| 477 | - | ||
| 478 | -class _LegalTextState extends State<_LegalText> { | ||
| 479 | - late final TapGestureRecognizer _linkRecognizer; | ||
| 480 | - | ||
| 481 | - @override | ||
| 482 | - void initState() { | ||
| 483 | - super.initState(); | ||
| 484 | - _linkRecognizer = TapGestureRecognizer()..onTap = widget.onLinkTap; | ||
| 485 | - } | ||
| 486 | - | ||
| 487 | - @override | ||
| 488 | - void didUpdateWidget(covariant _LegalText oldWidget) { | ||
| 489 | - super.didUpdateWidget(oldWidget); | ||
| 490 | - _linkRecognizer.onTap = widget.onLinkTap; | ||
| 491 | - } | ||
| 492 | - | ||
| 493 | - @override | ||
| 494 | - void dispose() { | ||
| 495 | - _linkRecognizer.dispose(); | ||
| 496 | - super.dispose(); | ||
| 497 | - } | ||
| 498 | - | ||
| 499 | - static const _baseStyle = TextStyle( | ||
| 500 | - color: PurchaseColors.subtitle, | ||
| 501 | - fontSize: 12, | ||
| 502 | - height: 1.4, | ||
| 503 | - ); | ||
| 504 | - | ||
| 505 | - static const _linkStyle = TextStyle( | ||
| 506 | - color: Color(0xFF845EEE), | ||
| 507 | - fontWeight: FontWeight.w500, | ||
| 508 | - decoration: TextDecoration.underline, | ||
| 509 | - decorationColor: Color(0xFF845EEE), | ||
| 510 | - ); | ||
| 511 | - | ||
| 512 | - TextSpan _buildTextSpan() => TextSpan( | ||
| 513 | - style: _baseStyle, | ||
| 514 | - children: [ | ||
| 515 | - TextSpan(text: widget.text), | ||
| 516 | - if (widget.linkText case final linkText?) | ||
| 517 | - TextSpan( | ||
| 518 | - text: linkText, | ||
| 519 | - style: _linkStyle, | ||
| 520 | - recognizer: widget.onLinkTap == null ? null : _linkRecognizer, | ||
| 521 | - ), | ||
| 522 | - if (widget.linkSuffix case final linkSuffix?) | ||
| 523 | - TextSpan(text: linkSuffix), | ||
| 524 | - ], | ||
| 525 | - ); | ||
| 526 | - | ||
| 527 | - @override | ||
| 528 | - Widget build(BuildContext context) { | ||
| 529 | - return Row( | ||
| 530 | - crossAxisAlignment: CrossAxisAlignment.start, | ||
| 531 | - children: [ | ||
| 532 | - Text( | ||
| 533 | - widget.prefix, | ||
| 534 | - style: const TextStyle( | ||
| 535 | - color: PurchaseColors.subtitle, | ||
| 536 | - fontSize: 12, | ||
| 537 | - height: 1.4, | ||
| 538 | - ), | ||
| 539 | - ), | ||
| 540 | - Expanded( | ||
| 541 | - child: Text.rich( | ||
| 542 | - _buildTextSpan(), | ||
| 543 | - textScaler: MediaQuery.textScalerOf(context), | ||
| 544 | - ), | ||
| 545 | - ), | ||
| 546 | - ], | ||
| 547 | - ); | ||
| 548 | - } | ||
| 549 | -} |
| 1 | +import 'package:doublefeel_flutter/core/util/size_extensions.dart'; | ||
| 2 | +import 'package:flutter/material.dart'; | ||
| 3 | +import 'package:get/get.dart'; | ||
| 4 | + | ||
| 5 | +/// Confirmation dialog for cancelling an OHOS auto-renewing subscription. | ||
| 6 | +class CancelSubscriptionDialog extends StatefulWidget { | ||
| 7 | + const CancelSubscriptionDialog({ | ||
| 8 | + super.key, | ||
| 9 | + required this.onConfirm, | ||
| 10 | + }); | ||
| 11 | + | ||
| 12 | + final Future<bool> Function() onConfirm; | ||
| 13 | + | ||
| 14 | + @override | ||
| 15 | + State<CancelSubscriptionDialog> createState() => | ||
| 16 | + _CancelSubscriptionDialogState(); | ||
| 17 | +} | ||
| 18 | + | ||
| 19 | +class _CancelSubscriptionDialogState extends State<CancelSubscriptionDialog> { | ||
| 20 | + bool _isSubmitting = false; | ||
| 21 | + | ||
| 22 | + @override | ||
| 23 | + Widget build(BuildContext context) { | ||
| 24 | + return PopScope( | ||
| 25 | + canPop: !_isSubmitting, | ||
| 26 | + child: Dialog( | ||
| 27 | + backgroundColor: Colors.transparent, | ||
| 28 | + insetPadding: EdgeInsets.zero, | ||
| 29 | + child: Container( | ||
| 30 | + width: 300.dp, | ||
| 31 | + padding: EdgeInsets.fromLTRB(24.dp, 24.dp, 24.dp, 20.dp), | ||
| 32 | + decoration: BoxDecoration( | ||
| 33 | + color: Colors.white, | ||
| 34 | + borderRadius: BorderRadius.circular(24.dp), | ||
| 35 | + ), | ||
| 36 | + child: Column( | ||
| 37 | + mainAxisSize: MainAxisSize.min, | ||
| 38 | + children: [ | ||
| 39 | + Image.asset( | ||
| 40 | + 'assets/images/common/ic_warning.png', | ||
| 41 | + width: 60.dp, | ||
| 42 | + height: 60.dp, | ||
| 43 | + ), | ||
| 44 | + SizedBox(height: 20.dp), | ||
| 45 | + Column( | ||
| 46 | + children: [ | ||
| 47 | + SizedBox( | ||
| 48 | + width: 220.dp, | ||
| 49 | + child: const Text( | ||
| 50 | + '确认取消自动续费?', | ||
| 51 | + textAlign: TextAlign.center, | ||
| 52 | + style: TextStyle( | ||
| 53 | + color: Color(0xFF141414), | ||
| 54 | + fontSize: 18, | ||
| 55 | + fontWeight: FontWeight.w600, | ||
| 56 | + height: 1.2, | ||
| 57 | + ), | ||
| 58 | + ), | ||
| 59 | + ), | ||
| 60 | + SizedBox(height: 16.dp), | ||
| 61 | + const Text( | ||
| 62 | + '取消后,DoubleFeel Pro 会员权益仍可使用至当前订阅周期结束,之后将不再自动续费或扣款。', | ||
| 63 | + textAlign: TextAlign.center, | ||
| 64 | + style: TextStyle( | ||
| 65 | + color: Color(0xFF6E6D80), | ||
| 66 | + fontSize: 15, | ||
| 67 | + fontWeight: FontWeight.w400, | ||
| 68 | + height: 1.35, | ||
| 69 | + ), | ||
| 70 | + ), | ||
| 71 | + ], | ||
| 72 | + ), | ||
| 73 | + SizedBox(height: 20.dp), | ||
| 74 | + _DialogButton( | ||
| 75 | + label: '确定', | ||
| 76 | + backgroundColor: const Color(0xFF845EEE), | ||
| 77 | + foregroundColor: Colors.white, | ||
| 78 | + loading: _isSubmitting, | ||
| 79 | + onTap: _isSubmitting ? null : _confirm, | ||
| 80 | + ), | ||
| 81 | + SizedBox(height: 4.dp), | ||
| 82 | + _DialogButton( | ||
| 83 | + label: '取消', | ||
| 84 | + foregroundColor: const Color(0xFF6E6D80), | ||
| 85 | + onTap: _isSubmitting ? null : Get.back, | ||
| 86 | + ), | ||
| 87 | + ], | ||
| 88 | + ), | ||
| 89 | + ), | ||
| 90 | + ), | ||
| 91 | + ); | ||
| 92 | + } | ||
| 93 | + | ||
| 94 | + Future<void> _confirm() async { | ||
| 95 | + if (_isSubmitting) return; | ||
| 96 | + | ||
| 97 | + setState(() => _isSubmitting = true); | ||
| 98 | + try { | ||
| 99 | + final shouldClose = await widget.onConfirm(); | ||
| 100 | + if (shouldClose && mounted && Get.isDialogOpen == true) { | ||
| 101 | + Get.back<void>(); | ||
| 102 | + } | ||
| 103 | + } finally { | ||
| 104 | + if (mounted) setState(() => _isSubmitting = false); | ||
| 105 | + } | ||
| 106 | + } | ||
| 107 | +} | ||
| 108 | + | ||
| 109 | +class _DialogButton extends StatelessWidget { | ||
| 110 | + const _DialogButton({ | ||
| 111 | + required this.label, | ||
| 112 | + required this.foregroundColor, | ||
| 113 | + required this.onTap, | ||
| 114 | + this.backgroundColor, | ||
| 115 | + this.loading = false, | ||
| 116 | + }); | ||
| 117 | + | ||
| 118 | + final String label; | ||
| 119 | + final Color foregroundColor; | ||
| 120 | + final Color? backgroundColor; | ||
| 121 | + final VoidCallback? onTap; | ||
| 122 | + final bool loading; | ||
| 123 | + | ||
| 124 | + @override | ||
| 125 | + Widget build(BuildContext context) { | ||
| 126 | + return GestureDetector( | ||
| 127 | + onTap: onTap, | ||
| 128 | + behavior: HitTestBehavior.opaque, | ||
| 129 | + child: Container( | ||
| 130 | + width: 220.dp, | ||
| 131 | + height: 48.dp, | ||
| 132 | + alignment: Alignment.center, | ||
| 133 | + decoration: BoxDecoration( | ||
| 134 | + color: backgroundColor, | ||
| 135 | + borderRadius: BorderRadius.circular(24.dp), | ||
| 136 | + ), | ||
| 137 | + child: loading | ||
| 138 | + ? SizedBox( | ||
| 139 | + width: 18.dp, | ||
| 140 | + height: 18.dp, | ||
| 141 | + child: const CircularProgressIndicator( | ||
| 142 | + color: Colors.white, | ||
| 143 | + strokeWidth: 2, | ||
| 144 | + ), | ||
| 145 | + ) | ||
| 146 | + : Text( | ||
| 147 | + label, | ||
| 148 | + style: TextStyle( | ||
| 149 | + color: foregroundColor, | ||
| 150 | + fontSize: 14, | ||
| 151 | + fontWeight: FontWeight.w500, | ||
| 152 | + height: 1.2, | ||
| 153 | + ), | ||
| 154 | + ), | ||
| 155 | + ), | ||
| 156 | + ); | ||
| 157 | + } | ||
| 158 | +} |
| @@ -484,6 +484,15 @@ class PurchaseController extends GetxController { | @@ -484,6 +484,15 @@ class PurchaseController extends GetxController { | ||
| 484 | feedbackCompat(pageName: 'Subscription page'); | 484 | feedbackCompat(pageName: 'Subscription page'); |
| 485 | } | 485 | } |
| 486 | 486 | ||
| 487 | + void openSupportEmail() { | ||
| 488 | + _trackClickVipCenter('邮件联系'); | ||
| 489 | + final userId = _userPreferences.preferences.value.meUserInfo?.id ?? 0; | ||
| 490 | + sendFeedbackEmail( | ||
| 491 | + userId: userId.toString(), | ||
| 492 | + pageName: 'Subscription page', | ||
| 493 | + ); | ||
| 494 | + } | ||
| 495 | + | ||
| 487 | void openUserTerms() { | 496 | void openUserTerms() { |
| 488 | String url = environmentConfig.region.value == AppRegion.china | 497 | String url = environmentConfig.region.value == AppRegion.china |
| 489 | ? AppConst.userTerms | 498 | ? AppConst.userTerms |
| @@ -504,6 +513,14 @@ class PurchaseController extends GetxController { | @@ -504,6 +513,14 @@ class PurchaseController extends GetxController { | ||
| 504 | ); | 513 | ); |
| 505 | } | 514 | } |
| 506 | 515 | ||
| 516 | + void openAutoRenewalAgreement() { | ||
| 517 | + _trackClickVipCenter('自动续费协议'); | ||
| 518 | + Get.toNamed( | ||
| 519 | + AppRoutes.webview, | ||
| 520 | + parameters: {'url': AppConst.autoRenewalAgreement}, | ||
| 521 | + ); | ||
| 522 | + } | ||
| 523 | + | ||
| 507 | Map<String, dynamic> get _baseAnalyticsProperties => | 524 | Map<String, dynamic> get _baseAnalyticsProperties => |
| 508 | {'channel_type': channelType}; | 525 | {'channel_type': channelType}; |
| 509 | 526 |
| 1 | import 'package:doublefeel_flutter/app/utils/assets_helper.dart'; | 1 | import 'package:doublefeel_flutter/app/utils/assets_helper.dart'; |
| 2 | -import 'package:doublefeel_flutter/app/utils/platform_compact.dart'; | ||
| 3 | import 'package:doublefeel_flutter/app/widget/payment_confirmation_bottom_sheet.dart'; | 2 | import 'package:doublefeel_flutter/app/widget/payment_confirmation_bottom_sheet.dart'; |
| 3 | +import 'package:doublefeel_flutter/app/widget/purchase_legal_notes.dart'; | ||
| 4 | import 'package:doublefeel_flutter/app/widget/premium_benefit_list_view.dart'; | 4 | import 'package:doublefeel_flutter/app/widget/premium_benefit_list_view.dart'; |
| 5 | import 'package:doublefeel_flutter/core/util/size_extensions.dart'; | 5 | import 'package:doublefeel_flutter/core/util/size_extensions.dart'; |
| 6 | import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; | 6 | import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; |
| 7 | import 'package:doublefeel_flutter/r.dart'; | 7 | import 'package:doublefeel_flutter/r.dart'; |
| 8 | -import 'package:flutter/gestures.dart'; | ||
| 9 | import 'package:flutter/material.dart'; | 8 | import 'package:flutter/material.dart'; |
| 10 | import 'package:flutter/services.dart'; | 9 | import 'package:flutter/services.dart'; |
| 11 | import 'package:get/get.dart'; | 10 | import 'package:get/get.dart'; |
| @@ -68,7 +67,16 @@ class PurchaseView extends GetView<PurchaseController> { | @@ -68,7 +67,16 @@ class PurchaseView extends GetView<PurchaseController> { | ||
| 68 | const SizedBox(height: 14), | 67 | const SizedBox(height: 14), |
| 69 | const PremiumBenefitListView(), | 68 | const PremiumBenefitListView(), |
| 70 | const SizedBox(height: 24), | 69 | const SizedBox(height: 24), |
| 71 | - _LegalNotes(controller: controller), | 70 | + controller.isOhos |
| 71 | + ? OhosPurchaseLegalNotes( | ||
| 72 | + onContactUsTap: controller.openContactUs, | ||
| 73 | + onSupportEmailTap: controller.openSupportEmail, | ||
| 74 | + ) | ||
| 75 | + : IosPurchaseLegalNotes( | ||
| 76 | + onRefundExplanationTap: | ||
| 77 | + controller.openRefundExplanation, | ||
| 78 | + onContactUsTap: controller.openContactUs, | ||
| 79 | + ), | ||
| 72 | ], | 80 | ], |
| 73 | ), | 81 | ), |
| 74 | ), | 82 | ), |
| @@ -112,6 +120,9 @@ class PurchaseView extends GetView<PurchaseController> { | @@ -112,6 +120,9 @@ class PurchaseView extends GetView<PurchaseController> { | ||
| 112 | }, | 120 | }, |
| 113 | onTermsTap: controller.openUserTerms, | 121 | onTermsTap: controller.openUserTerms, |
| 114 | onPrivacyTap: controller.openPrivacyPolicy, | 122 | onPrivacyTap: controller.openPrivacyPolicy, |
| 123 | + showAutoRenewalAgreement: controller.isOhos, | ||
| 124 | + onAutoRenewalAgreementTap: | ||
| 125 | + controller.openAutoRenewalAgreement, | ||
| 115 | ), | 126 | ), |
| 116 | ), | 127 | ), |
| 117 | ), | 128 | ), |
| @@ -258,190 +269,3 @@ class _PurchaseNavigationBar extends StatelessWidget { | @@ -258,190 +269,3 @@ class _PurchaseNavigationBar extends StatelessWidget { | ||
| 258 | ); | 269 | ); |
| 259 | } | 270 | } |
| 260 | } | 271 | } |
| 261 | - | ||
| 262 | -class _LegalNotes extends StatelessWidget { | ||
| 263 | - const _LegalNotes({required this.controller}); | ||
| 264 | - | ||
| 265 | - final PurchaseController controller; | ||
| 266 | - | ||
| 267 | - @override | ||
| 268 | - Widget build(BuildContext context) { | ||
| 269 | - if (isOhos()) { | ||
| 270 | - return Padding( | ||
| 271 | - padding: const EdgeInsets.symmetric(horizontal: 16), | ||
| 272 | - child: Column( | ||
| 273 | - crossAxisAlignment: CrossAxisAlignment.start, | ||
| 274 | - children: [ | ||
| 275 | - Text( | ||
| 276 | - context.l10n.purchaseNotesTitle, | ||
| 277 | - style: const TextStyle( | ||
| 278 | - color: Colors.black, | ||
| 279 | - fontSize: 18, | ||
| 280 | - fontWeight: FontWeight.w600, | ||
| 281 | - height: 1.2, | ||
| 282 | - ), | ||
| 283 | - ), | ||
| 284 | - const SizedBox(height: 24), | ||
| 285 | - const _LegalText( | ||
| 286 | - prefix: '1. ', | ||
| 287 | - text: '确认支付成功后,会员权益将立即生效。', | ||
| 288 | - ), | ||
| 289 | - const SizedBox(height: 12), | ||
| 290 | - const _LegalText( | ||
| 291 | - prefix: '2. ', | ||
| 292 | - text: 'DoubleFeel Pro 会员属于虚拟服务商品,购买后除法律法规另有规定外,通常不支持退款。', | ||
| 293 | - ), | ||
| 294 | - const SizedBox(height: 12), | ||
| 295 | - const _LegalText( | ||
| 296 | - prefix: '3. ', | ||
| 297 | - text: '如支付成功后会员未生效,请尝试重新登录账号。', | ||
| 298 | - ), | ||
| 299 | - const SizedBox(height: 12), | ||
| 300 | - _LegalText( | ||
| 301 | - prefix: '4. ', | ||
| 302 | - text: '如果有更多其它问题请', | ||
| 303 | - linkText: context.l10n.purchaseLinkContactUs, | ||
| 304 | - linkSuffix: '。', | ||
| 305 | - onLinkTap: controller.openContactUs, | ||
| 306 | - ), | ||
| 307 | - ], | ||
| 308 | - ), | ||
| 309 | - ); | ||
| 310 | - } | ||
| 311 | - return Padding( | ||
| 312 | - padding: const EdgeInsets.symmetric(horizontal: 16), | ||
| 313 | - child: Column( | ||
| 314 | - crossAxisAlignment: CrossAxisAlignment.start, | ||
| 315 | - children: [ | ||
| 316 | - Text( | ||
| 317 | - context.l10n.purchaseNotesTitle, | ||
| 318 | - style: const TextStyle( | ||
| 319 | - color: Colors.black, | ||
| 320 | - fontSize: 18, | ||
| 321 | - fontWeight: FontWeight.w600, | ||
| 322 | - height: 1.2, | ||
| 323 | - ), | ||
| 324 | - ), | ||
| 325 | - const SizedBox(height: 24), | ||
| 326 | - _LegalText( | ||
| 327 | - prefix: '1. ', | ||
| 328 | - text: context.l10n.purchaseNoteSubscription, | ||
| 329 | - linkText: context.l10n.purchaseLinkLearnMore, | ||
| 330 | - linkSuffix: '。', | ||
| 331 | - onLinkTap: controller.openRefundExplanation, | ||
| 332 | - ), | ||
| 333 | - const SizedBox(height: 12), | ||
| 334 | - /* | ||
| 335 | - _LegalText( | ||
| 336 | - prefix: '3. ', | ||
| 337 | - text: context.l10n.purchaseNoteRestore, | ||
| 338 | - linkText: context.l10n.purchaseRestore, | ||
| 339 | - linkSuffix: '。', | ||
| 340 | - onLinkTap: controller.restorePurchase, | ||
| 341 | - ), | ||
| 342 | - const SizedBox(height: 12), | ||
| 343 | - */ | ||
| 344 | - _LegalText( | ||
| 345 | - prefix: '2. ', | ||
| 346 | - text: context.l10n.purchaseNoteContact, | ||
| 347 | - linkText: context.l10n.purchaseLinkContactUs, | ||
| 348 | - linkSuffix: '。', | ||
| 349 | - onLinkTap: controller.openContactUs, | ||
| 350 | - ), | ||
| 351 | - ], | ||
| 352 | - ), | ||
| 353 | - ); | ||
| 354 | - } | ||
| 355 | -} | ||
| 356 | - | ||
| 357 | -class _LegalText extends StatefulWidget { | ||
| 358 | - const _LegalText({ | ||
| 359 | - required this.prefix, | ||
| 360 | - required this.text, | ||
| 361 | - this.linkText, | ||
| 362 | - this.linkSuffix, | ||
| 363 | - this.onLinkTap, | ||
| 364 | - }); | ||
| 365 | - | ||
| 366 | - final String prefix; | ||
| 367 | - final String text; | ||
| 368 | - final String? linkText; | ||
| 369 | - final String? linkSuffix; | ||
| 370 | - final VoidCallback? onLinkTap; | ||
| 371 | - | ||
| 372 | - @override | ||
| 373 | - State<_LegalText> createState() => _LegalTextState(); | ||
| 374 | -} | ||
| 375 | - | ||
| 376 | -class _LegalTextState extends State<_LegalText> { | ||
| 377 | - late final TapGestureRecognizer _linkRecognizer; | ||
| 378 | - | ||
| 379 | - @override | ||
| 380 | - void initState() { | ||
| 381 | - super.initState(); | ||
| 382 | - _linkRecognizer = TapGestureRecognizer()..onTap = widget.onLinkTap; | ||
| 383 | - } | ||
| 384 | - | ||
| 385 | - @override | ||
| 386 | - void didUpdateWidget(covariant _LegalText oldWidget) { | ||
| 387 | - super.didUpdateWidget(oldWidget); | ||
| 388 | - _linkRecognizer.onTap = widget.onLinkTap; | ||
| 389 | - } | ||
| 390 | - | ||
| 391 | - @override | ||
| 392 | - void dispose() { | ||
| 393 | - _linkRecognizer.dispose(); | ||
| 394 | - super.dispose(); | ||
| 395 | - } | ||
| 396 | - | ||
| 397 | - static const _baseStyle = TextStyle( | ||
| 398 | - color: PurchaseColors.subtitle, | ||
| 399 | - fontSize: 12, | ||
| 400 | - height: 1.4, | ||
| 401 | - ); | ||
| 402 | - | ||
| 403 | - static const _linkStyle = TextStyle( | ||
| 404 | - color: Color(0xFF845EEE), | ||
| 405 | - fontWeight: FontWeight.w500, | ||
| 406 | - decoration: TextDecoration.underline, | ||
| 407 | - decorationColor: Color(0xFF845EEE), | ||
| 408 | - ); | ||
| 409 | - | ||
| 410 | - TextSpan _buildTextSpan() => TextSpan( | ||
| 411 | - style: _baseStyle, | ||
| 412 | - children: [ | ||
| 413 | - TextSpan(text: widget.text), | ||
| 414 | - if (widget.linkText case final linkText?) | ||
| 415 | - TextSpan( | ||
| 416 | - text: linkText, | ||
| 417 | - style: _linkStyle, | ||
| 418 | - recognizer: widget.onLinkTap == null ? null : _linkRecognizer, | ||
| 419 | - ), | ||
| 420 | - if (widget.linkSuffix case final linkSuffix?) | ||
| 421 | - TextSpan(text: linkSuffix), | ||
| 422 | - ], | ||
| 423 | - ); | ||
| 424 | - | ||
| 425 | - @override | ||
| 426 | - Widget build(BuildContext context) { | ||
| 427 | - return Row( | ||
| 428 | - crossAxisAlignment: CrossAxisAlignment.start, | ||
| 429 | - children: [ | ||
| 430 | - Text( | ||
| 431 | - widget.prefix, | ||
| 432 | - style: const TextStyle( | ||
| 433 | - color: PurchaseColors.subtitle, | ||
| 434 | - fontSize: 12, | ||
| 435 | - height: 1.4, | ||
| 436 | - ), | ||
| 437 | - ), | ||
| 438 | - Expanded( | ||
| 439 | - child: Text.rich( | ||
| 440 | - _buildTextSpan(), | ||
| 441 | - textScaler: MediaQuery.textScalerOf(context), | ||
| 442 | - ), | ||
| 443 | - ), | ||
| 444 | - ], | ||
| 445 | - ); | ||
| 446 | - } | ||
| 447 | -} |
| @@ -13,6 +13,8 @@ class PurchaseBottomBar extends StatelessWidget { | @@ -13,6 +13,8 @@ class PurchaseBottomBar extends StatelessWidget { | ||
| 13 | required this.onPressed, | 13 | required this.onPressed, |
| 14 | this.onTermsTap, | 14 | this.onTermsTap, |
| 15 | this.onPrivacyTap, | 15 | this.onPrivacyTap, |
| 16 | + this.showAutoRenewalAgreement = false, | ||
| 17 | + this.onAutoRenewalAgreementTap, | ||
| 16 | }); | 18 | }); |
| 17 | 19 | ||
| 18 | final String label; | 20 | final String label; |
| @@ -20,6 +22,8 @@ class PurchaseBottomBar extends StatelessWidget { | @@ -20,6 +22,8 @@ class PurchaseBottomBar extends StatelessWidget { | ||
| 20 | final VoidCallback onPressed; | 22 | final VoidCallback onPressed; |
| 21 | final VoidCallback? onTermsTap; | 23 | final VoidCallback? onTermsTap; |
| 22 | final VoidCallback? onPrivacyTap; | 24 | final VoidCallback? onPrivacyTap; |
| 25 | + final bool showAutoRenewalAgreement; | ||
| 26 | + final VoidCallback? onAutoRenewalAgreementTap; | ||
| 23 | 27 | ||
| 24 | @override | 28 | @override |
| 25 | Widget build(BuildContext context) { | 29 | Widget build(BuildContext context) { |
| @@ -103,6 +107,22 @@ class PurchaseBottomBar extends StatelessWidget { | @@ -103,6 +107,22 @@ class PurchaseBottomBar extends StatelessWidget { | ||
| 103 | label: context.l10n.purchasePrivacyPolicy, | 107 | label: context.l10n.purchasePrivacyPolicy, |
| 104 | onTap: onPrivacyTap, | 108 | onTap: onPrivacyTap, |
| 105 | ), | 109 | ), |
| 110 | + if (showAutoRenewalAgreement) ...[ | ||
| 111 | + const Padding( | ||
| 112 | + padding: EdgeInsets.symmetric(horizontal: 8), | ||
| 113 | + child: Text( | ||
| 114 | + '|', | ||
| 115 | + style: TextStyle( | ||
| 116 | + color: PurchaseColors.subtitle, | ||
| 117 | + fontSize: 12, | ||
| 118 | + ), | ||
| 119 | + ), | ||
| 120 | + ), | ||
| 121 | + _BottomLink( | ||
| 122 | + label: '自动续费协议', | ||
| 123 | + onTap: onAutoRenewalAgreementTap, | ||
| 124 | + ), | ||
| 125 | + ], | ||
| 106 | ], | 126 | ], |
| 107 | ), | 127 | ), |
| 108 | const SizedBox(height: 18), | 128 | const SizedBox(height: 18), |
lib/app/widget/purchase_legal_notes.dart
0 → 100644
| 1 | +import 'package:doublefeel_flutter/app/modules/purchase/widgets/purchase_colors.dart'; | ||
| 2 | +import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; | ||
| 3 | +import 'package:flutter/gestures.dart'; | ||
| 4 | +import 'package:flutter/material.dart'; | ||
| 5 | + | ||
| 6 | +/// OHOS subscription terms shown on the purchase and membership pages. | ||
| 7 | +class OhosPurchaseLegalNotes extends StatelessWidget { | ||
| 8 | + const OhosPurchaseLegalNotes({ | ||
| 9 | + super.key, | ||
| 10 | + required this.onContactUsTap, | ||
| 11 | + required this.onSupportEmailTap, | ||
| 12 | + }); | ||
| 13 | + | ||
| 14 | + final VoidCallback onContactUsTap; | ||
| 15 | + final VoidCallback onSupportEmailTap; | ||
| 16 | + | ||
| 17 | + @override | ||
| 18 | + Widget build(BuildContext context) { | ||
| 19 | + return Padding( | ||
| 20 | + padding: const EdgeInsets.symmetric(horizontal: 16), | ||
| 21 | + child: Column( | ||
| 22 | + crossAxisAlignment: CrossAxisAlignment.start, | ||
| 23 | + children: [ | ||
| 24 | + Text( | ||
| 25 | + context.l10n.purchaseNotesTitle, | ||
| 26 | + style: const TextStyle( | ||
| 27 | + color: Colors.black, | ||
| 28 | + fontSize: 18, | ||
| 29 | + fontWeight: FontWeight.w600, | ||
| 30 | + height: 1.2, | ||
| 31 | + ), | ||
| 32 | + ), | ||
| 33 | + const SizedBox(height: 24), | ||
| 34 | + const _LegalText( | ||
| 35 | + prefix: '1. ', | ||
| 36 | + spans: [ | ||
| 37 | + _LegalTextSpan( | ||
| 38 | + text: '本服务为自动续费订阅服务,¥68/年或¥16/月,到期后将按所选订阅周期自动续费并扣款。', | ||
| 39 | + ), | ||
| 40 | + ], | ||
| 41 | + ), | ||
| 42 | + const SizedBox(height: 12), | ||
| 43 | + const _LegalText( | ||
| 44 | + prefix: '2. ', | ||
| 45 | + spans: [ | ||
| 46 | + _LegalTextSpan( | ||
| 47 | + text: | ||
| 48 | + '如需取消自动续费,请前往支付宝 > 我的 > 设置 > 支付设置 > 免密支付/自动扣款 > 关闭对应服务。取消后,当前会员权益可使用至本周期结束,下个周期将不再扣费。', | ||
| 49 | + ), | ||
| 50 | + ], | ||
| 51 | + ), | ||
| 52 | + const SizedBox(height: 12), | ||
| 53 | + const _LegalText( | ||
| 54 | + prefix: '3. ', | ||
| 55 | + spans: [ | ||
| 56 | + _LegalTextSpan( | ||
| 57 | + text: '确认支付成功后,会员权益立即生效。如支付成功后会员未生效,请尝试重新登录账号。', | ||
| 58 | + ), | ||
| 59 | + ], | ||
| 60 | + ), | ||
| 61 | + const SizedBox(height: 12), | ||
| 62 | + const _LegalText( | ||
| 63 | + prefix: '4. ', | ||
| 64 | + spans: [ | ||
| 65 | + _LegalTextSpan( | ||
| 66 | + text: 'DoubleFeel Pro 属于虚拟服务商品,购买后除法律法规另有规定外,通常不支持退款。', | ||
| 67 | + ), | ||
| 68 | + ], | ||
| 69 | + ), | ||
| 70 | + const SizedBox(height: 12), | ||
| 71 | + _LegalText( | ||
| 72 | + prefix: '5. ', | ||
| 73 | + spans: const [ | ||
| 74 | + _LegalTextSpan(text: '如有其他问题,请'), | ||
| 75 | + _LegalTextSpan(text: '联系我们', isLink: true), | ||
| 76 | + _LegalTextSpan(text: ',或发送邮件至'), | ||
| 77 | + _LegalTextSpan(text: 'support@doublefeel.cn', isLink: true), | ||
| 78 | + _LegalTextSpan(text: '。'), | ||
| 79 | + ], | ||
| 80 | + onLinkTaps: [onContactUsTap, onSupportEmailTap], | ||
| 81 | + ), | ||
| 82 | + ], | ||
| 83 | + ), | ||
| 84 | + ); | ||
| 85 | + } | ||
| 86 | +} | ||
| 87 | + | ||
| 88 | +/// iOS subscription terms shown on the purchase and membership pages. | ||
| 89 | +class IosPurchaseLegalNotes extends StatelessWidget { | ||
| 90 | + const IosPurchaseLegalNotes({ | ||
| 91 | + required this.onRefundExplanationTap, | ||
| 92 | + required this.onContactUsTap, | ||
| 93 | + }); | ||
| 94 | + | ||
| 95 | + final VoidCallback onRefundExplanationTap; | ||
| 96 | + final VoidCallback onContactUsTap; | ||
| 97 | + | ||
| 98 | + @override | ||
| 99 | + Widget build(BuildContext context) { | ||
| 100 | + return Padding( | ||
| 101 | + padding: const EdgeInsets.symmetric(horizontal: 16), | ||
| 102 | + child: Column( | ||
| 103 | + crossAxisAlignment: CrossAxisAlignment.start, | ||
| 104 | + children: [ | ||
| 105 | + Text( | ||
| 106 | + context.l10n.purchaseNotesTitle, | ||
| 107 | + style: const TextStyle( | ||
| 108 | + color: Colors.black, | ||
| 109 | + fontSize: 18, | ||
| 110 | + fontWeight: FontWeight.w600, | ||
| 111 | + height: 1.2, | ||
| 112 | + ), | ||
| 113 | + ), | ||
| 114 | + const SizedBox(height: 24), | ||
| 115 | + _LegalText( | ||
| 116 | + prefix: '1. ', | ||
| 117 | + spans: [ | ||
| 118 | + _LegalTextSpan(text: context.l10n.purchaseNoteSubscription), | ||
| 119 | + _LegalTextSpan( | ||
| 120 | + text: context.l10n.purchaseLinkLearnMore, | ||
| 121 | + isLink: true, | ||
| 122 | + ), | ||
| 123 | + const _LegalTextSpan(text: '。'), | ||
| 124 | + ], | ||
| 125 | + onLinkTaps: [onRefundExplanationTap], | ||
| 126 | + ), | ||
| 127 | + const SizedBox(height: 12), | ||
| 128 | + _LegalText( | ||
| 129 | + prefix: '2. ', | ||
| 130 | + spans: [ | ||
| 131 | + _LegalTextSpan(text: context.l10n.purchaseNoteContact), | ||
| 132 | + _LegalTextSpan( | ||
| 133 | + text: context.l10n.purchaseLinkContactUs, | ||
| 134 | + isLink: true, | ||
| 135 | + ), | ||
| 136 | + const _LegalTextSpan(text: '。'), | ||
| 137 | + ], | ||
| 138 | + onLinkTaps: [onContactUsTap], | ||
| 139 | + ), | ||
| 140 | + ], | ||
| 141 | + ), | ||
| 142 | + ); | ||
| 143 | + } | ||
| 144 | +} | ||
| 145 | + | ||
| 146 | +class _LegalTextSpan { | ||
| 147 | + const _LegalTextSpan({required this.text, this.isLink = false}); | ||
| 148 | + | ||
| 149 | + final String text; | ||
| 150 | + final bool isLink; | ||
| 151 | +} | ||
| 152 | + | ||
| 153 | +class _LegalText extends StatefulWidget { | ||
| 154 | + const _LegalText({ | ||
| 155 | + required this.prefix, | ||
| 156 | + required this.spans, | ||
| 157 | + this.onLinkTaps = const [], | ||
| 158 | + }); | ||
| 159 | + | ||
| 160 | + final String prefix; | ||
| 161 | + final List<_LegalTextSpan> spans; | ||
| 162 | + final List<VoidCallback> onLinkTaps; | ||
| 163 | + | ||
| 164 | + @override | ||
| 165 | + State<_LegalText> createState() => _LegalTextState(); | ||
| 166 | +} | ||
| 167 | + | ||
| 168 | +class _LegalTextState extends State<_LegalText> { | ||
| 169 | + late List<TapGestureRecognizer> _linkRecognizers; | ||
| 170 | + | ||
| 171 | + @override | ||
| 172 | + void initState() { | ||
| 173 | + super.initState(); | ||
| 174 | + _linkRecognizers = _createRecognizers(); | ||
| 175 | + } | ||
| 176 | + | ||
| 177 | + @override | ||
| 178 | + void didUpdateWidget(covariant _LegalText oldWidget) { | ||
| 179 | + super.didUpdateWidget(oldWidget); | ||
| 180 | + for (var index = 0; index < _linkRecognizers.length; index++) { | ||
| 181 | + _linkRecognizers[index].onTap = widget.onLinkTaps[index]; | ||
| 182 | + } | ||
| 183 | + } | ||
| 184 | + | ||
| 185 | + List<TapGestureRecognizer> _createRecognizers() => [ | ||
| 186 | + for (final onTap in widget.onLinkTaps) | ||
| 187 | + TapGestureRecognizer()..onTap = onTap, | ||
| 188 | + ]; | ||
| 189 | + | ||
| 190 | + @override | ||
| 191 | + void dispose() { | ||
| 192 | + for (final recognizer in _linkRecognizers) { | ||
| 193 | + recognizer.dispose(); | ||
| 194 | + } | ||
| 195 | + super.dispose(); | ||
| 196 | + } | ||
| 197 | + | ||
| 198 | + static const _baseStyle = TextStyle( | ||
| 199 | + color: PurchaseColors.subtitle, | ||
| 200 | + fontSize: 12, | ||
| 201 | + height: 1.4, | ||
| 202 | + ); | ||
| 203 | + | ||
| 204 | + static const _linkStyle = TextStyle( | ||
| 205 | + color: Color(0xFF845EEE), | ||
| 206 | + fontWeight: FontWeight.w500, | ||
| 207 | + decoration: TextDecoration.underline, | ||
| 208 | + decorationColor: Color(0xFF845EEE), | ||
| 209 | + ); | ||
| 210 | + | ||
| 211 | + @override | ||
| 212 | + Widget build(BuildContext context) { | ||
| 213 | + var linkIndex = 0; | ||
| 214 | + return Row( | ||
| 215 | + crossAxisAlignment: CrossAxisAlignment.start, | ||
| 216 | + children: [ | ||
| 217 | + Text(widget.prefix, style: _baseStyle), | ||
| 218 | + Expanded( | ||
| 219 | + child: Text.rich( | ||
| 220 | + TextSpan( | ||
| 221 | + style: _baseStyle, | ||
| 222 | + children: [ | ||
| 223 | + for (final span in widget.spans) | ||
| 224 | + TextSpan( | ||
| 225 | + text: span.text, | ||
| 226 | + style: span.isLink ? _linkStyle : null, | ||
| 227 | + recognizer: | ||
| 228 | + span.isLink ? _linkRecognizers[linkIndex++] : null, | ||
| 229 | + ), | ||
| 230 | + ], | ||
| 231 | + ), | ||
| 232 | + textScaler: MediaQuery.textScalerOf(context), | ||
| 233 | + ), | ||
| 234 | + ), | ||
| 235 | + ], | ||
| 236 | + ); | ||
| 237 | + } | ||
| 238 | +} |
| @@ -28,6 +28,8 @@ abstract final class AppConst { | @@ -28,6 +28,8 @@ abstract final class AppConst { | ||
| 28 | 'https://cdn.doublefeel.cn/doublefeel/protocol/DoubleFeel%E7%94%A8%E6%88%B7%E5%8D%8F%E8%AE%AE.html'; | 28 | 'https://cdn.doublefeel.cn/doublefeel/protocol/DoubleFeel%E7%94%A8%E6%88%B7%E5%8D%8F%E8%AE%AE.html'; |
| 29 | static const String privacyPolicy = | 29 | static const String privacyPolicy = |
| 30 | 'https://cdn.doublefeel.cn/doublefeel/protocol/DoubleFeel%E9%9A%90%E7%A7%81%E5%8D%8F%E8%AE%AE.html'; | 30 | 'https://cdn.doublefeel.cn/doublefeel/protocol/DoubleFeel%E9%9A%90%E7%A7%81%E5%8D%8F%E8%AE%AE.html'; |
| 31 | + static const String autoRenewalAgreement = | ||
| 32 | + 'http://cdn.doublefeel.cn/doublefeel/protocol/DoubleFeel_HarmonyOS_Auto_Renewal_Agreement.html'; | ||
| 31 | static const String userTermsGlobal = | 33 | static const String userTermsGlobal = |
| 32 | 'https://cdn-oversea.doublefeel.cn/doublefeel/protocol/DoubleFeel_Terms_of_Service.html'; | 34 | 'https://cdn-oversea.doublefeel.cn/doublefeel/protocol/DoubleFeel_Terms_of_Service.html'; |
| 33 | static const String privacyPolicyGlobal = | 35 | static const String privacyPolicyGlobal = |
| @@ -29,8 +29,9 @@ class AppErrorHandler { | @@ -29,8 +29,9 @@ class AppErrorHandler { | ||
| 29 | _handleAccessTokenInvalid(accessToken); | 29 | _handleAccessTokenInvalid(accessToken); |
| 30 | return; | 30 | return; |
| 31 | } | 31 | } |
| 32 | - if (businessCode == null || | ||
| 33 | - !policy.excludeErrorCodeList.contains(businessCode)) { | 32 | + if (policy.showBusinessMessage && |
| 33 | + (businessCode == null || | ||
| 34 | + !policy.excludeErrorCodeList.contains(businessCode))) { | ||
| 34 | if (businessMessage != null) { | 35 | if (businessMessage != null) { |
| 35 | _showMessage(businessMessage); | 36 | _showMessage(businessMessage); |
| 36 | } | 37 | } |
| 1 | -/// Controls global HTTP error side effects (toast, logout, etc.). | 1 | +/// Controls global HTTP error side effects (business-error toast, logout, etc.). |
| 2 | /// | 2 | /// |
| 3 | -/// Pass an instance to [safeCall] to enable toast / logout behaviour. | 3 | +/// Pass an instance to [safeCall] to enable logout handling; business-error |
| 4 | +/// messages can be suppressed while keeping authentication handling active. | ||
| 4 | /// Omit (pass null) to handle errors silently at the call site. | 5 | /// Omit (pass null) to handle errors silently at the call site. |
| 5 | class HttpErrorHandlingPolicy { | 6 | class HttpErrorHandlingPolicy { |
| 6 | - const HttpErrorHandlingPolicy({this.excludeErrorCodeList = const {}}); | 7 | + const HttpErrorHandlingPolicy({ |
| 8 | + this.excludeErrorCodeList = const {}, | ||
| 9 | + this.showBusinessMessage = true, | ||
| 10 | + }); | ||
| 7 | 11 | ||
| 8 | final Set<int> excludeErrorCodeList; | 12 | final Set<int> excludeErrorCodeList; |
| 13 | + final bool showBusinessMessage; | ||
| 9 | 14 | ||
| 10 | /// Default: show errors, no exclusions. | 15 | /// Default: show errors, no exclusions. |
| 11 | static const HttpErrorHandlingPolicy defaultPolicy = | 16 | static const HttpErrorHandlingPolicy defaultPolicy = |
| @@ -2,6 +2,7 @@ import 'package:doublefeel_flutter/app/utils/platform_compact.dart'; | @@ -2,6 +2,7 @@ import 'package:doublefeel_flutter/app/utils/platform_compact.dart'; | ||
| 2 | import 'package:doublefeel_flutter/data/models/pay/apple_pay_order_response.dart'; | 2 | import 'package:doublefeel_flutter/data/models/pay/apple_pay_order_response.dart'; |
| 3 | 3 | ||
| 4 | import '../../../data/models/pay/pay_models.dart'; | 4 | import '../../../data/models/pay/pay_models.dart'; |
| 5 | +import '../../error/http_error_handling_policy.dart'; | ||
| 5 | import '../../result/app_result.dart'; | 6 | import '../../result/app_result.dart'; |
| 6 | import '../../result/safe_call.dart'; | 7 | import '../../result/safe_call.dart'; |
| 7 | import '../api_paths.dart'; | 8 | import '../api_paths.dart'; |
| @@ -107,7 +108,11 @@ class PayApi { | @@ -107,7 +108,11 @@ class PayApi { | ||
| 107 | ); | 108 | ); |
| 108 | } | 109 | } |
| 109 | 110 | ||
| 110 | - Future<AppResult<void>> cancelSubscription(String agreementNo) { | 111 | + Future<AppResult<void>> cancelSubscription( |
| 112 | + String agreementNo, { | ||
| 113 | + HttpErrorHandlingPolicy? errorHandlingPolicy = | ||
| 114 | + HttpErrorHandlingPolicy.defaultPolicy, | ||
| 115 | + }) { | ||
| 111 | return safeCall( | 116 | return safeCall( |
| 112 | call: () async { | 117 | call: () async { |
| 113 | await _dioClient.dio.post( | 118 | await _dioClient.dio.post( |
| @@ -115,6 +120,7 @@ class PayApi { | @@ -115,6 +120,7 @@ class PayApi { | ||
| 115 | data: SubscriptionCancelRequest(agreementNo: agreementNo).toJson(), | 120 | data: SubscriptionCancelRequest(agreementNo: agreementNo).toJson(), |
| 116 | ); | 121 | ); |
| 117 | }, | 122 | }, |
| 123 | + errorHandlingPolicy: errorHandlingPolicy, | ||
| 118 | ); | 124 | ); |
| 119 | } | 125 | } |
| 120 | } | 126 | } |
| @@ -2,7 +2,7 @@ | @@ -2,7 +2,7 @@ | ||
| 2 | "app": { | 2 | "app": { |
| 3 | "bundleName": "com.doublefeel.hmapp", | 3 | "bundleName": "com.doublefeel.hmapp", |
| 4 | "vendor": "DiDi", | 4 | "vendor": "DiDi", |
| 5 | - "versionCode": 101, | 5 | + "versionCode": 103, |
| 6 | "versionName": "2.7.0", | 6 | "versionName": "2.7.0", |
| 7 | "icon": "$media:layered_image", | 7 | "icon": "$media:layered_image", |
| 8 | "label": "$string:app_name" | 8 | "label": "$string:app_name" |
| @@ -2,7 +2,7 @@ name: doublefeel_flutter | @@ -2,7 +2,7 @@ name: doublefeel_flutter | ||
| 2 | description: "A new Flutter project." | 2 | description: "A new Flutter project." |
| 3 | publish_to: 'none' | 3 | publish_to: 'none' |
| 4 | 4 | ||
| 5 | -version: 2.7.0+101 | 5 | +version: 2.7.0+103 |
| 6 | 6 | ||
| 7 | environment: | 7 | environment: |
| 8 | sdk: ^3.6.2 | 8 | sdk: ^3.6.2 |
-
Please register or login to post a comment