Jelajahi Sumber

feat: 通知提示功能

lilu 3 bulan lalu
induk
melakukan
da588c932e

TEMPAT SAMPAH
android/app/libs/libxray.aar


+ 2 - 2
android/app/src/main/kotlin/app/xixi/nomo/XRayService.kt

@@ -79,7 +79,7 @@ class XRayService : LifecycleVpnService() {
         VLog.i(TAG, "XRayService onCreate")
 
         Seq.setContext(applicationContext)
-        Ixvpn_mobile.initProxyConnector()
+        Ixvpn_mobile.initProxyConnector(0)
         registerNotificationChannel(
             this,
             NOTIFICATION_CHANNEL_ID,
@@ -537,7 +537,7 @@ class XRayService : LifecycleVpnService() {
                 }
 
                 // 更新通知
-                updateTimerNotification(currentTime)
+//                updateTimerNotification(currentTime)
 
                 // 发送计时更新广播
 //                sendTimerUpdate(currentTime)

+ 2 - 0
lib/app/constants/enums.dart

@@ -69,6 +69,8 @@ enum LogModule {
   NM_ApiLaunchLog, // launch接口报错
   NM_ApiRouterLog, // router接口报错
   NM_ApiOtherLog, // 其他接口报错
+  NM_RateLog, // 评分统计
+  NM_FeedbackLog, // 反馈提交
 }
 
 enum ConnectionState {

+ 3 - 0
lib/app/constants/sp_keys.dart

@@ -83,4 +83,7 @@ class SPKeys {
 
   /// 上次更新提醒的版本号
   static const String lastUpgradeNoticeVersion = 'last_upgrade_notice_version';
+
+  /// 上次通知权限提醒时间(毫秒时间戳)
+  static const String lastPushNoticeTime = 'last_push_notice_time';
 }

+ 2 - 2
lib/app/controllers/api_controller.dart

@@ -590,7 +590,7 @@ class ApiController extends GetxService with WidgetsBindingObserver {
         final lastNoticeTimeStr =
             IXSP.getString(SPKeys.lastUpgradeNoticeTime) ?? '0';
         final lastNoticeTime = int.tryParse(lastNoticeTimeStr) ?? 0;
-        final now = DateTime.now().millisecondsSinceEpoch;
+        final now = NtpTimeService().getCurrentTimestamp();
         final elapsedMinutes = (now - lastNoticeTime) / (1000 * 60);
 
         if (elapsedMinutes >= upgradeNoticeMinutes) {
@@ -616,7 +616,7 @@ class ApiController extends GetxService with WidgetsBindingObserver {
     // 记录本次提醒时间
     IXSP.setString(
       SPKeys.lastUpgradeNoticeTime,
-      DateTime.now().millisecondsSinceEpoch.toString(),
+      NtpTimeService().getCurrentTimestamp().toString(),
     );
     // 记录本次提醒的版本号
     IXSP.setString(

+ 1 - 0
lib/app/data/models/launch/app_config.dart

@@ -51,6 +51,7 @@ class AppConfig with _$AppConfig {
     int? serverTime,
     int? vipRemainNotice,
     int? upgradeNoticeTime,
+    int? pushNoticeTime,
     SmartGeo? smartGeo,
   }) = _AppConfig;
 

+ 24 - 1
lib/app/data/models/launch/app_config.freezed.dart

@@ -64,6 +64,7 @@ mixin _$AppConfig {
   int? get serverTime => throw _privateConstructorUsedError;
   int? get vipRemainNotice => throw _privateConstructorUsedError;
   int? get upgradeNoticeTime => throw _privateConstructorUsedError;
+  int? get pushNoticeTime => throw _privateConstructorUsedError;
   SmartGeo? get smartGeo => throw _privateConstructorUsedError;
 
   /// Serializes this AppConfig to a JSON map.
@@ -123,6 +124,7 @@ abstract class $AppConfigCopyWith<$Res> {
     int? serverTime,
     int? vipRemainNotice,
     int? upgradeNoticeTime,
+    int? pushNoticeTime,
     SmartGeo? smartGeo,
   });
 
@@ -185,6 +187,7 @@ class _$AppConfigCopyWithImpl<$Res, $Val extends AppConfig>
     Object? serverTime = freezed,
     Object? vipRemainNotice = freezed,
     Object? upgradeNoticeTime = freezed,
+    Object? pushNoticeTime = freezed,
     Object? smartGeo = freezed,
   }) {
     return _then(
@@ -353,6 +356,10 @@ class _$AppConfigCopyWithImpl<$Res, $Val extends AppConfig>
                 ? _value.upgradeNoticeTime
                 : upgradeNoticeTime // ignore: cast_nullable_to_non_nullable
                       as int?,
+            pushNoticeTime: freezed == pushNoticeTime
+                ? _value.pushNoticeTime
+                : pushNoticeTime // ignore: cast_nullable_to_non_nullable
+                      as int?,
             smartGeo: freezed == smartGeo
                 ? _value.smartGeo
                 : smartGeo // ignore: cast_nullable_to_non_nullable
@@ -428,6 +435,7 @@ abstract class _$$AppConfigImplCopyWith<$Res>
     int? serverTime,
     int? vipRemainNotice,
     int? upgradeNoticeTime,
+    int? pushNoticeTime,
     SmartGeo? smartGeo,
   });
 
@@ -490,6 +498,7 @@ class __$$AppConfigImplCopyWithImpl<$Res>
     Object? serverTime = freezed,
     Object? vipRemainNotice = freezed,
     Object? upgradeNoticeTime = freezed,
+    Object? pushNoticeTime = freezed,
     Object? smartGeo = freezed,
   }) {
     return _then(
@@ -658,6 +667,10 @@ class __$$AppConfigImplCopyWithImpl<$Res>
             ? _value.upgradeNoticeTime
             : upgradeNoticeTime // ignore: cast_nullable_to_non_nullable
                   as int?,
+        pushNoticeTime: freezed == pushNoticeTime
+            ? _value.pushNoticeTime
+            : pushNoticeTime // ignore: cast_nullable_to_non_nullable
+                  as int?,
         smartGeo: freezed == smartGeo
             ? _value.smartGeo
             : smartGeo // ignore: cast_nullable_to_non_nullable
@@ -712,6 +725,7 @@ class _$AppConfigImpl with DiagnosticableTreeMixin implements _AppConfig {
     this.serverTime,
     this.vipRemainNotice,
     this.upgradeNoticeTime,
+    this.pushNoticeTime,
     this.smartGeo,
   }) : _apiIps = apiIps,
        _apiUrls = apiUrls,
@@ -979,11 +993,13 @@ class _$AppConfigImpl with DiagnosticableTreeMixin implements _AppConfig {
   @override
   final int? upgradeNoticeTime;
   @override
+  final int? pushNoticeTime;
+  @override
   final SmartGeo? smartGeo;
 
   @override
   String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) {
-    return 'AppConfig(apiIps: $apiIps, apiUrls: $apiUrls, routerApiUrls: $routerApiUrls, appStatUrls: $appStatUrls, assetUrls: $assetUrls, logFileUploadUrls: $logFileUploadUrls, realityApiUrls: $realityApiUrls, realityAppStatUrls: $realityAppStatUrls, realityLogFileUploadUrls: $realityLogFileUploadUrls, autoPing: $autoPing, autoPingInterval: $autoPingInterval, backupApiUrls: $backupApiUrls, cacheDataEffectiveDays: $cacheDataEffectiveDays, contacts: $contacts, follows: $follows, providers: $providers, signalThresholds: $signalThresholds, packetLossThresholds: $packetLossThresholds, skipGeo: $skipGeo, speedTestTargetList: $speedTestTargetList, websiteUrl: $websiteUrl, visitorDisabled: $visitorDisabled, privacyAgreement: $privacyAgreement, email: $email, blackPkgs: $blackPkgs, whitePkgs: $whitePkgs, accelerationSampleCount: $accelerationSampleCount, minAccelerationSampleCount: $minAccelerationSampleCount, connectionWarningThreshold: $connectionWarningThreshold, connectiveTestUrl: $connectiveTestUrl, disabledLogModules: $disabledLogModules, realTimeDbPath: $realTimeDbPath, boostTimeInterval: $boostTimeInterval, pingDisplayMode: $pingDisplayMode, peekTimeInterval: $peekTimeInterval, enableAd: $enableAd, adTimeoutDuration: $adTimeoutDuration, reportActiveInterval: $reportActiveInterval, serverTime: $serverTime, vipRemainNotice: $vipRemainNotice, upgradeNoticeTime: $upgradeNoticeTime, smartGeo: $smartGeo)';
+    return 'AppConfig(apiIps: $apiIps, apiUrls: $apiUrls, routerApiUrls: $routerApiUrls, appStatUrls: $appStatUrls, assetUrls: $assetUrls, logFileUploadUrls: $logFileUploadUrls, realityApiUrls: $realityApiUrls, realityAppStatUrls: $realityAppStatUrls, realityLogFileUploadUrls: $realityLogFileUploadUrls, autoPing: $autoPing, autoPingInterval: $autoPingInterval, backupApiUrls: $backupApiUrls, cacheDataEffectiveDays: $cacheDataEffectiveDays, contacts: $contacts, follows: $follows, providers: $providers, signalThresholds: $signalThresholds, packetLossThresholds: $packetLossThresholds, skipGeo: $skipGeo, speedTestTargetList: $speedTestTargetList, websiteUrl: $websiteUrl, visitorDisabled: $visitorDisabled, privacyAgreement: $privacyAgreement, email: $email, blackPkgs: $blackPkgs, whitePkgs: $whitePkgs, accelerationSampleCount: $accelerationSampleCount, minAccelerationSampleCount: $minAccelerationSampleCount, connectionWarningThreshold: $connectionWarningThreshold, connectiveTestUrl: $connectiveTestUrl, disabledLogModules: $disabledLogModules, realTimeDbPath: $realTimeDbPath, boostTimeInterval: $boostTimeInterval, pingDisplayMode: $pingDisplayMode, peekTimeInterval: $peekTimeInterval, enableAd: $enableAd, adTimeoutDuration: $adTimeoutDuration, reportActiveInterval: $reportActiveInterval, serverTime: $serverTime, vipRemainNotice: $vipRemainNotice, upgradeNoticeTime: $upgradeNoticeTime, pushNoticeTime: $pushNoticeTime, smartGeo: $smartGeo)';
   }
 
   @override
@@ -1051,6 +1067,7 @@ class _$AppConfigImpl with DiagnosticableTreeMixin implements _AppConfig {
       ..add(DiagnosticsProperty('serverTime', serverTime))
       ..add(DiagnosticsProperty('vipRemainNotice', vipRemainNotice))
       ..add(DiagnosticsProperty('upgradeNoticeTime', upgradeNoticeTime))
+      ..add(DiagnosticsProperty('pushNoticeTime', pushNoticeTime))
       ..add(DiagnosticsProperty('smartGeo', smartGeo));
   }
 
@@ -1176,6 +1193,8 @@ class _$AppConfigImpl with DiagnosticableTreeMixin implements _AppConfig {
                 other.vipRemainNotice == vipRemainNotice) &&
             (identical(other.upgradeNoticeTime, upgradeNoticeTime) ||
                 other.upgradeNoticeTime == upgradeNoticeTime) &&
+            (identical(other.pushNoticeTime, pushNoticeTime) ||
+                other.pushNoticeTime == pushNoticeTime) &&
             (identical(other.smartGeo, smartGeo) ||
                 other.smartGeo == smartGeo));
   }
@@ -1225,6 +1244,7 @@ class _$AppConfigImpl with DiagnosticableTreeMixin implements _AppConfig {
     serverTime,
     vipRemainNotice,
     upgradeNoticeTime,
+    pushNoticeTime,
     smartGeo,
   ]);
 
@@ -1285,6 +1305,7 @@ abstract class _AppConfig implements AppConfig {
     final int? serverTime,
     final int? vipRemainNotice,
     final int? upgradeNoticeTime,
+    final int? pushNoticeTime,
     final SmartGeo? smartGeo,
   }) = _$AppConfigImpl;
 
@@ -1374,6 +1395,8 @@ abstract class _AppConfig implements AppConfig {
   @override
   int? get upgradeNoticeTime;
   @override
+  int? get pushNoticeTime;
+  @override
   SmartGeo? get smartGeo;
 
   /// Create a copy of AppConfig

+ 2 - 0
lib/app/data/models/launch/app_config.g.dart

@@ -92,6 +92,7 @@ _$AppConfigImpl _$$AppConfigImplFromJson(
   serverTime: (json['serverTime'] as num?)?.toInt(),
   vipRemainNotice: (json['vipRemainNotice'] as num?)?.toInt(),
   upgradeNoticeTime: (json['upgradeNoticeTime'] as num?)?.toInt(),
+  pushNoticeTime: (json['pushNoticeTime'] as num?)?.toInt(),
   smartGeo: json['smartGeo'] == null
       ? null
       : SmartGeo.fromJson(json['smartGeo'] as Map<String, dynamic>),
@@ -140,6 +141,7 @@ Map<String, dynamic> _$$AppConfigImplToJson(_$AppConfigImpl instance) =>
       'serverTime': instance.serverTime,
       'vipRemainNotice': instance.vipRemainNotice,
       'upgradeNoticeTime': instance.upgradeNoticeTime,
+      'pushNoticeTime': instance.pushNoticeTime,
       'smartGeo': instance.smartGeo,
     };
 

+ 31 - 5
lib/app/modules/feedback/controllers/feedback_controller.dart

@@ -1,13 +1,22 @@
 import 'package:flutter/material.dart';
 import 'package:get/get.dart';
 
+import '../../../constants/enums.dart';
+import '../../../controllers/api_controller.dart';
+
 class FeedbackController extends GetxController {
+  final apiController = Get.find<ApiController>();
   // 反馈内容控制器
   final feedbackController = TextEditingController();
 
   // 邮箱控制器
   final emailController = TextEditingController();
 
+  // 反馈内容焦点
+  final feedbackFocusNode = FocusNode();
+  // 邮箱焦点
+  final emailFocusNode = FocusNode();
+
   // 是否正在提交
   final isSubmitting = false.obs;
 
@@ -16,18 +25,28 @@ class FeedbackController extends GetxController {
     super.onInit();
   }
 
-  @override
-  void onReady() {
-    super.onReady();
-  }
-
   @override
   void onClose() {
     feedbackController.dispose();
     emailController.dispose();
+    feedbackFocusNode.dispose();
+    emailFocusNode.dispose();
     super.onClose();
   }
 
+  void onBackPressed() {
+    // 判断软键盘是否弹出
+    if (feedbackFocusNode.hasFocus || emailFocusNode.hasFocus) {
+      FocusManager.instance.primaryFocus?.unfocus();
+      // 延迟100毫秒后关闭页面
+      Future.delayed(const Duration(milliseconds: 250), () {
+        Get.back();
+      });
+    } else {
+      Get.back();
+    }
+  }
+
   /// 提交反馈
   void submitFeedback() async {
     if (feedbackController.text.trim().isEmpty) {
@@ -51,6 +70,13 @@ class FeedbackController extends GetxController {
     try {
       // 模拟提交过程
       await Future.delayed(const Duration(seconds: 2));
+      await apiController.uploadApiStatisticsLog([
+        {
+          'module': LogModule.NM_FeedbackLog.name,
+          'feedback': feedbackController.text.trim(),
+          'email': emailController.text.trim(),
+        },
+      ]);
 
       // 提交成功
       Get.snackbar('成功', '反馈已提交,我们会尽快回复您');

+ 6 - 1
lib/app/modules/feedback/views/feedback_view.dart

@@ -14,7 +14,10 @@ class FeedbackView extends BaseView<FeedbackController> {
   const FeedbackView({super.key});
 
   @override
-  PreferredSizeWidget? get appBar => IXAppBar(title: Strings.feedback.tr);
+  PreferredSizeWidget? get appBar => IXAppBar(
+    title: Strings.feedback.tr,
+    onBackPressed: controller.onBackPressed,
+  );
 
   @override
   Widget buildContent(BuildContext context) {
@@ -42,6 +45,7 @@ class FeedbackView extends BaseView<FeedbackController> {
                   expands: true,
                   textAlignVertical: TextAlignVertical.top,
                   cursorHeight: 18.w,
+                  focusNode: controller.feedbackFocusNode,
                   style: TextStyle(
                     fontSize: 16.sp,
                     height: 1.4,
@@ -85,6 +89,7 @@ class FeedbackView extends BaseView<FeedbackController> {
                 maxLines: 1, // 邮箱输入通常只需要一行
                 cursorHeight: 18.w,
                 scrollPadding: EdgeInsets.zero,
+                focusNode: controller.emailFocusNode,
                 style: TextStyle(
                   fontSize: 16.sp,
                   height: 1.4,

+ 4 - 0
lib/app/modules/home/controllers/home_controller.dart

@@ -77,6 +77,10 @@ class HomeController extends BaseController {
     AwesomeNotificationsHelper.init();
     getBanner(position: 'nine');
     getBanner(position: 'banner');
+    // 延迟检查通知权限,避免阻塞初始化
+    Future.delayed(const Duration(milliseconds: 500), () {
+      AwesomeNotificationsHelper.showPushNoticeDialog();
+    });
   }
 
   /// 初始化位置数据

+ 13 - 0
lib/app/modules/login/controllers/login_controller.dart

@@ -32,6 +32,19 @@ class LoginController extends GetxController {
     super.onClose();
   }
 
+  void onBackPressed() {
+    // 判断软键盘是否弹出
+    if (usernameFocusNode.hasFocus || passwordFocusNode.hasFocus) {
+      FocusManager.instance.primaryFocus?.unfocus();
+      // 延迟100毫秒后关闭页面
+      Future.delayed(const Duration(milliseconds: 250), () {
+        Get.back();
+      });
+    } else {
+      Get.back();
+    }
+  }
+
   void checkLogin() {
     final username = usernameController.text;
     final password = passwordController.text;

+ 2 - 1
lib/app/modules/login/views/login_view.dart

@@ -18,7 +18,8 @@ class LoginView extends BaseView<LoginController> {
   const LoginView({super.key});
 
   @override
-  PreferredSizeWidget? get appBar => IXAppBar(title: '');
+  PreferredSizeWidget? get appBar =>
+      IXAppBar(title: '', onBackPressed: controller.onBackPressed);
 
   @override
   Widget buildContent(BuildContext context) {

+ 13 - 14
lib/app/modules/setting/views/setting_view.dart

@@ -342,20 +342,19 @@ class SettingView extends BaseView<SettingController> {
             //     ),
             //   ),
             // ),
-            _buildDivider(),
-            _buildSettingItem(
-              icon: IconFont.icon35,
-              iconColor: Get.reactiveTheme.primaryColor,
-              title: Strings.restoreDefault.tr,
-              trailing: Icon(
-                IconFont.icon02,
-                size: 20.w,
-                color: Get.reactiveTheme.hintColor,
-              ),
-              onTap: () {
-                // TODO: 恢复默认设置
-              },
-            ),
+            // _buildSettingItem(
+            //   icon: IconFont.icon35,
+            //   iconColor: Get.reactiveTheme.primaryColor,
+            //   title: Strings.restoreDefault.tr,
+            //   trailing: Icon(
+            //     IconFont.icon02,
+            //     size: 20.w,
+            //     color: Get.reactiveTheme.hintColor,
+            //   ),
+            //   onTap: () {
+            //     // TODO: 恢复默认设置
+            //   },
+            // ),
           ],
         ),
       ),

+ 5 - 0
lib/config/translations/strings_enum.dart

@@ -454,4 +454,9 @@ class Strings {
   static const String remainTime = 'Remain time';
   static const String remainTimeEnded = 'Your available time has ended';
   static const String expired = 'Expired';
+
+  static const String enableNotifications = 'Enable Notifications';
+  static const String enableNotificationsDesc =
+      'Enable notifications to receive important updates and messages.';
+  static const String enable = 'Enable';
 }

+ 85 - 0
lib/utils/awesome_notifications_helper.dart

@@ -2,8 +2,13 @@ import 'package:awesome_notifications/awesome_notifications.dart';
 import 'package:flutter/material.dart';
 import 'package:get/get.dart';
 
+import '../app/constants/sp_keys.dart';
+import '../app/dialog/custom_dialog.dart';
+import '../app/data/sp/ix_sp.dart';
 import '../app/routes/app_pages.dart';
+import '../config/translations/strings_enum.dart';
 import 'log/logger.dart';
+import 'ntp_time_service.dart';
 
 class AwesomeNotificationsHelper {
   // prevent making instance
@@ -61,6 +66,86 @@ class AwesomeNotificationsHelper {
     return isAllowed;
   }
 
+  /// 检查是否应该显示通知权限提醒弹窗
+  /// 返回 true 表示应该显示
+  static Future<bool> shouldShowPushNoticeDialog() async {
+    try {
+      // 如果已经开启通知权限,不显示
+      final isAllowed = await checkNotificationPermission();
+      if (isAllowed) {
+        log(TAG, 'Notification already allowed, skip dialog');
+        return false;
+      }
+
+      // 检查是否超过 pushNoticeTime 分钟
+      final appConfig = IXSP.getAppConfig();
+      final pushNoticeMinutes = appConfig?.pushNoticeTime ?? 10080; // 默认7天
+      final lastNoticeTimeStr =
+          IXSP.getString(SPKeys.lastPushNoticeTime) ?? '0';
+      final lastNoticeTime = int.tryParse(lastNoticeTimeStr) ?? 0;
+      final now = NtpTimeService().getCurrentTimestamp();
+
+      // 第一次(lastNoticeTime == 0)或超过间隔时间
+      if (lastNoticeTime == 0) {
+        log(TAG, 'First time, should show push notice dialog');
+        return true;
+      }
+
+      final elapsedMinutes = (now - lastNoticeTime) / (1000 * 60);
+      if (elapsedMinutes >= pushNoticeMinutes) {
+        log(
+          TAG,
+          'Push notice time elapsed: ${elapsedMinutes.toStringAsFixed(1)}min >= ${pushNoticeMinutes}min',
+        );
+        return true;
+      }
+
+      log(
+        TAG,
+        'Push notice skipped: ${elapsedMinutes.toStringAsFixed(1)}min elapsed, '
+        'need ${pushNoticeMinutes}min',
+      );
+      return false;
+    } catch (e) {
+      log(TAG, 'Error checking push notice: $e');
+      return false;
+    }
+  }
+
+  /// 显示通知权限提醒弹窗
+  static Future<void> showPushNoticeDialog() async {
+    final shouldShow = await shouldShowPushNoticeDialog();
+    if (!shouldShow) return;
+
+    // 记录本次提醒时间
+    _saveLastPushNoticeTime();
+
+    CustomDialog.showConfirm(
+      title: Strings.enableNotifications.tr,
+      message: Strings.enableNotificationsDesc.tr,
+      confirmText: Strings.enable.tr,
+      cancelText: Strings.notNow.tr,
+      icon: Icons.notifications_active_outlined,
+      iconColor: Get.theme.primaryColor,
+      confirmButtonColor: Get.theme.primaryColor,
+      onConfirm: () async {
+        Navigator.of(Get.context!).pop();
+        await requestNotificationPermission();
+      },
+      onCancel: () {
+        Navigator.of(Get.context!).pop();
+      },
+    );
+  }
+
+  /// 记录上次提醒时间
+  static void _saveLastPushNoticeTime() {
+    IXSP.setString(
+      SPKeys.lastPushNoticeTime,
+      NtpTimeService().getCurrentTimestamp().toString(),
+    );
+  }
+
   /// when user click on notification or click on button on the notification
   static listenToActionButtons() {
     // Only after at least the action method is set, the notification events are delivered