Flutter本地通知系统:记账提醒的深度实现

Flutter本地通知系统:记账提醒的深度实现

本文基于BeeCount(蜜蜂记账)项目的实际开发经验,深入探讨如何构建可靠的本地通知提醒系统,涵盖Android精确闹钟、电池优化处理等高级特性。

项目背景

BeeCount(蜜蜂记账)是一款开源、简洁、无广告的个人记账应用。所有财务数据完全由用户掌控,支持本地存储和可选的云端同步,确保数据绝对安全。

引言

良好的记账习惯需要持续的提醒和督促。现代移动设备的电池管理策略越来越严格,如何确保通知在各种系统限制下依然可靠送达,成为了移动应用开发的重要挑战。BeeCount通过深度的系统集成和优化策略,实现了高可靠性的记账提醒功能。

通知系统架构

整体架构设计

scss 复制代码
┌─────────────────┐    ┌──────────────────┐    ┌─────────────────┐
│   Flutter UI    │    │ Notification     │    │   Android       │
│   (Settings)    │◄──►│ Service Layer    │◄──►│   Native Layer  │
│                 │    │                  │    │                 │
└─────────────────┘    └──────────────────┘    └─────────────────┘
         │                       │                       │
         └───── 用户配置 ─────────┼───── 定时调度 ────────┘
                                │
                    ┌──────────────────┐
                    │   SQLite         │
                    │   (提醒记录)      │
                    └──────────────────┘

核心设计原则

  1. 系统兼容性:适配Android 6.0-14的电池优化策略
  2. 精确调度:使用AlarmManager确保定时准确
  3. 持久化存储:提醒配置和历史记录本地化存储
  4. 用户体验:智能权限引导和状态反馈
  5. 资源优化:最小化系统资源占用

通知服务核心实现

服务接口定义

dart 复制代码
abstract class NotificationService {
  /// 初始化通知服务
  Future<bool> initialize();

  /// 调度通知
  Future<bool> scheduleNotification({
    required int id,
    required String title,
    required String body,
    required DateTime scheduledTime,
  });

  /// 取消通知
  Future<bool> cancelNotification(int id);

  /// 取消所有通知
  Future<bool> cancelAllNotifications();

  /// 检查通知权限
  Future<bool> hasNotificationPermission();

  /// 请求通知权限
  Future<bool> requestNotificationPermission();

  /// 检查电池优化状态
  Future<bool> isBatteryOptimizationIgnored();

  /// 请求忽略电池优化
  Future<bool> requestIgnoreBatteryOptimization();
}

通知服务实现

dart 复制代码
class FlutterNotificationService implements NotificationService {
  static const MethodChannel _channel = MethodChannel('com.example.beecount/notification');
  final FlutterLocalNotificationsPlugin _plugin = FlutterLocalNotificationsPlugin();

  static const String _channelId = 'accounting_reminder';
  static const String _channelName = '记账提醒';
  static const String _channelDescription = '定时提醒用户记账';

  @override
  Future<bool> initialize() async {
    try {
      // Android通知渠道配置
      const androidInitSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
      const initSettings = InitializationSettings(android: androidInitSettings);

      await _plugin.initialize(
        initSettings,
        onDidReceiveNotificationResponse: _onNotificationTapped,
      );

      // 创建通知渠道
      await _createNotificationChannel();

      logI('NotificationService', '✅ 通知服务初始化成功');
      return true;
    } catch (e) {
      logE('NotificationService', '❌ 通知服务初始化失败', e);
      return false;
    }
  }

  Future<void> _createNotificationChannel() async {
    const androidChannel = AndroidNotificationChannel(
      _channelId,
      _channelName,
      description: _channelDescription,
      importance: Importance.high,
      priority: Priority.high,
      enableVibration: true,
      enableLights: true,
      ledColor: Color(0xFF2196F3),
      sound: RawResourceAndroidNotificationSound('notification_sound'),
    );

    await _plugin
        .resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
        ?.createNotificationChannel(androidChannel);
  }

  @override
  Future<bool> scheduleNotification({
    required int id,
    required String title,
    required String body,
    required DateTime scheduledTime,
  }) async {
    try {
      // 检查权限状态
      if (!await hasNotificationPermission()) {
        logW('NotificationService', '⚠️ 缺少通知权限,无法调度通知');
        return false;
      }

      // 使用原生Android AlarmManager进行精确调度
      final result = await _channel.invokeMethod('scheduleNotification', {
        'title': title,
        'body': body,
        'scheduledTimeMillis': scheduledTime.millisecondsSinceEpoch,
        'notificationId': id,
      });

      if (result == true) {
        logI('NotificationService', '📅 通知调度成功: $id at ${scheduledTime.toString()}');
        return true;
      } else {
        logE('NotificationService', '❌ 通知调度失败: $id');
        return false;
      }
    } catch (e) {
      logE('NotificationService', '❌ 调度通知异常', e);
      return false;
    }
  }

  @override
  Future<bool> cancelNotification(int id) async {
    try {
      await _channel.invokeMethod('cancelNotification', {'notificationId': id});
      await _plugin.cancel(id);

      logI('NotificationService', '🗑️ 取消通知: $id');
      return true;
    } catch (e) {
      logE('NotificationService', '❌ 取消通知失败', e);
      return false;
    }
  }

  @override
  Future<bool> hasNotificationPermission() async {
    try {
      final result = await _plugin
          .resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
          ?.areNotificationsEnabled();

      return result ?? false;
    } catch (e) {
      logE('NotificationService', '❌ 检查通知权限失败', e);
      return false;
    }
  }

  @override
  Future<bool> isBatteryOptimizationIgnored() async {
    try {
      final result = await _channel.invokeMethod('isIgnoringBatteryOptimizations');
      return result == true;
    } catch (e) {
      logE('NotificationService', '❌ 检查电池优化状态失败', e);
      return false;
    }
  }

  @override
  Future<bool> requestIgnoreBatteryOptimization() async {
    try {
      await _channel.invokeMethod('requestIgnoreBatteryOptimizations');
      return true;
    } catch (e) {
      logE('NotificationService', '❌ 请求电池优化豁免失败', e);
      return false;
    }
  }

  void _onNotificationTapped(NotificationResponse response) {
    logI('NotificationService', '👆 用户点击通知: ${response.id}');

    // 通知点击事件可以用于打开特定页面或执行特定操作
    // 例如直接跳转到记账页面
    _handleNotificationAction(response);
  }

  void _handleNotificationAction(NotificationResponse response) {
    // 处理通知点击逻辑
    // 可以通过路由或事件总线通知应用
    NotificationClickEvent(
      notificationId: response.id ?? 0,
      payload: response.payload,
    ).fire();
  }
}

Android原生集成

MainActivity通知方法实现

kotlin 复制代码
class MainActivity : FlutterActivity() {
    private val CHANNEL = "com.example.beecount/notification"
    private lateinit var notificationManager: NotificationManager
    private lateinit var alarmManager: AlarmManager

    override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)

        notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
        alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager

        MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
            .setMethodCallHandler { call, result ->
                when (call.method) {
                    "scheduleNotification" -> {
                        val title = call.argument<String>("title") ?: "记账提醒"
                        val body = call.argument<String>("body") ?: "别忘了记录今天的收支哦 💰"
                        val scheduledTimeMillis = call.argument<Long>("scheduledTimeMillis") ?: 0
                        val notificationId = call.argument<Int>("notificationId") ?: 1001

                        scheduleNotification(title, body, scheduledTimeMillis, notificationId)
                        result.success(true)
                    }
                    "cancelNotification" -> {
                        val notificationId = call.argument<Int>("notificationId") ?: 1001
                        cancelNotification(notificationId)
                        result.success(true)
                    }
                    "isIgnoringBatteryOptimizations" -> {
                        result.success(isIgnoringBatteryOptimizations())
                    }
                    "requestIgnoreBatteryOptimizations" -> {
                        requestIgnoreBatteryOptimizations()
                        result.success(true)
                    }
                    else -> result.notImplemented()
                }
            }
    }

    private fun scheduleNotification(title: String, body: String, scheduledTimeMillis: Long, notificationId: Int) {
        try {
            android.util.Log.d("MainActivity", "📅 调度通知: ID=$notificationId, 时间=$scheduledTimeMillis")

            // 检查精确闹钟权限 (Android 12+)
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
                if (!alarmManager.canScheduleExactAlarms()) {
                    android.util.Log.w("MainActivity", "⚠️ 没有精确闹钟权限,尝试请求权限")
                    try {
                        val intent = Intent(android.provider.Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM)
                        startActivity(intent)
                    } catch (e: Exception) {
                        android.util.Log.e("MainActivity", "无法打开精确闹钟权限设置: $e")
                    }
                    return
                }
            }

            // 计算时间差用于调试
            val currentTime = System.currentTimeMillis()
            val timeDiff = scheduledTimeMillis - currentTime
            android.util.Log.d("MainActivity", "当前时间: $currentTime")
            android.util.Log.d("MainActivity", "调度时间: $scheduledTimeMillis")
            android.util.Log.d("MainActivity", "时间差: ${timeDiff / 1000}秒")

            if (timeDiff <= 0) {
                android.util.Log.w("MainActivity", "⚠️ 调度时间已过期,将调度到明天同一时间")
                // 自动调整到第二天同一时间
                val tomorrow = scheduledTimeMillis + 24 * 60 * 60 * 1000
                scheduleNotification(title, body, tomorrow, notificationId)
                return
            }

            // 创建PendingIntent
            val intent = Intent(this, NotificationReceiver::class.java).apply {
                putExtra("title", title)
                putExtra("body", body)
                putExtra("notificationId", notificationId)
                action = "${packageName}.NOTIFICATION_ALARM"
            }

            val pendingIntent = PendingIntent.getBroadcast(
                this,
                notificationId,
                intent,
                PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
            )

            // 使用精确闹钟调度
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
                alarmManager.setExactAndAllowWhileIdle(
                    AlarmManager.RTC_WAKEUP,
                    scheduledTimeMillis,
                    pendingIntent
                )
            } else {
                alarmManager.setExact(
                    AlarmManager.RTC_WAKEUP,
                    scheduledTimeMillis,
                    pendingIntent
                )
            }

            android.util.Log.d("MainActivity", "✅ 通知调度成功: ID=$notificationId")
        } catch (e: Exception) {
            android.util.Log.e("MainActivity", "❌ 调度通知失败: $e")
        }
    }

    private fun cancelNotification(notificationId: Int) {
        try {
            // 取消AlarmManager中的定时任务
            val intent = Intent(this, NotificationReceiver::class.java)
            val pendingIntent = PendingIntent.getBroadcast(
                this,
                notificationId,
                intent,
                PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
            )
            alarmManager.cancel(pendingIntent)

            // 取消已显示的通知
            notificationManager.cancel(notificationId)

            android.util.Log.d("MainActivity", "🗑️ 通知已取消: ID=$notificationId")
        } catch (e: Exception) {
            android.util.Log.e("MainActivity", "❌ 取消通知失败: $e")
        }
    }

    private fun isIgnoringBatteryOptimizations(): Boolean {
        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
            powerManager.isIgnoringBatteryOptimizations(packageName)
        } else {
            true // Android 6.0以下版本无电池优化
        }
    }

    private fun requestIgnoreBatteryOptimizations() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
            if (!powerManager.isIgnoringBatteryOptimizations(packageName)) {
                val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
                    data = Uri.parse("package:$packageName")
                }
                try {
                    startActivity(intent)
                } catch (e: Exception) {
                    // 如果无法打开请求页面,则打开应用设置
                    openAppSettings()
                }
            }
        }
    }

    private fun openAppSettings() {
        val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
            data = Uri.parse("package:$packageName")
        }
        startActivity(intent)
    }
}

BroadcastReceiver实现

kotlin 复制代码
class NotificationReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        android.util.Log.d("NotificationReceiver", "📨 收到广播: ${intent.action}")

        when (intent.action) {
            "${context.packageName}.NOTIFICATION_ALARM" -> {
                showNotification(context, intent)
            }
            Intent.ACTION_BOOT_COMPLETED,
            Intent.ACTION_MY_PACKAGE_REPLACED,
            Intent.ACTION_PACKAGE_REPLACED -> {
                android.util.Log.d("NotificationReceiver", "🔄 系统启动或应用更新,重新调度通知")
                rescheduleNotifications(context)
            }
        }
    }

    private fun showNotification(context: Context, intent: Intent) {
        try {
            val title = intent.getStringExtra("title") ?: "记账提醒"
            val body = intent.getStringExtra("body") ?: "别忘了记录今天的收支哦 💰"
            val notificationId = intent.getIntExtra("notificationId", 1001)

            android.util.Log.d("NotificationReceiver", "📢 显示通知: $title")

            val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager

            // 创建点击Intent
            val clickIntent = Intent(context, NotificationClickReceiver::class.java).apply {
                putExtra("notificationId", notificationId)
                action = "${context.packageName}.NOTIFICATION_CLICK"
            }

            val clickPendingIntent = PendingIntent.getBroadcast(
                context,
                notificationId,
                clickIntent,
                PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
            )

            // 构建通知
            val notification = NotificationCompat.Builder(context, "accounting_reminder")
                .setSmallIcon(R.mipmap.ic_launcher)
                .setContentTitle(title)
                .setContentText(body)
                .setStyle(NotificationCompat.BigTextStyle().bigText(body))
                .setPriority(NotificationCompat.PRIORITY_HIGH)
                .setDefaults(NotificationCompat.DEFAULT_ALL)
                .setAutoCancel(true)
                .setContentIntent(clickPendingIntent)
                .build()

            notificationManager.notify(notificationId, notification)

            // 自动重新调度下一次提醒(如果是重复提醒)
            rescheduleNextNotification(context, notificationId)

        } catch (e: Exception) {
            android.util.Log.e("NotificationReceiver", "❌ 显示通知失败: $e")
        }
    }

    private fun rescheduleNextNotification(context: Context, notificationId: Int) {
        // 这里可以根据用户设置重新调度下一次提醒
        // 例如每日提醒会自动调度到明天同一时间
        try {
            // 通过SharedPreferences或数据库获取用户的提醒设置
            val sharedPrefs = context.getSharedPreferences("notification_settings", Context.MODE_PRIVATE)
            val isRepeating = sharedPrefs.getBoolean("is_repeating_$notificationId", false)

            if (isRepeating) {
                android.util.Log.d("NotificationReceiver", "🔄 重新调度重复提醒: $notificationId")
                // 通知Flutter层重新调度
                // 这里可以通过本地广播或其他方式通知Flutter
            }
        } catch (e: Exception) {
            android.util.Log.e("NotificationReceiver", "❌ 重新调度失败: $e")
        }
    }

    private fun rescheduleNotifications(context: Context) {
        // 系统启动后重新调度所有通知
        // 实际实现中,这里应该从数据库读取所有活跃的提醒设置
        android.util.Log.d("NotificationReceiver", "📅 重新调度所有通知")

        try {
            // 发送广播给Flutter,让其重新调度所有通知
            val intent = Intent("com.example.beecount.RESCHEDULE_NOTIFICATIONS")
            context.sendBroadcast(intent)
        } catch (e: Exception) {
            android.util.Log.e("NotificationReceiver", "❌ 重新调度广播发送失败: $e")
        }
    }
}

class NotificationClickReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        val notificationId = intent.getIntExtra("notificationId", 0)
        android.util.Log.d("NotificationClickReceiver", "👆 通知被点击: $notificationId")

        try {
            // 启动应用主界面
            val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)
            if (launchIntent != null) {
                launchIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
                launchIntent.putExtra("notification_clicked", true)
                launchIntent.putExtra("notification_id", notificationId)
                context.startActivity(launchIntent)
            }
        } catch (e: Exception) {
            android.util.Log.e("NotificationClickReceiver", "❌ 启动应用失败: $e")
        }
    }
}

权限管理系统

权限检查和引导

dart 复制代码
class PermissionGuideService {
  final NotificationService _notificationService;

  PermissionGuideService(this._notificationService);

  /// 检查所有必需的权限
  Future<PermissionStatus> checkAllPermissions() async {
    final permissions = <PermissionType, bool>{};

    // 检查通知权限
    permissions[PermissionType.notification] =
        await _notificationService.hasNotificationPermission();

    // 检查电池优化豁免
    permissions[PermissionType.batteryOptimization] =
        await _notificationService.isBatteryOptimizationIgnored();

    return PermissionStatus(permissions: permissions);
  }

  /// 引导用户完成权限设置
  Future<bool> guideUserThroughPermissions(BuildContext context) async {
    final status = await checkAllPermissions();

    if (status.isAllGranted) {
      return true;
    }

    return await _showPermissionGuideDialog(context, status);
  }

  Future<bool> _showPermissionGuideDialog(
    BuildContext context,
    PermissionStatus status
  ) async {
    final steps = <PermissionStep>[];

    if (!status.hasNotificationPermission) {
      steps.add(PermissionStep(
        type: PermissionType.notification,
        title: '开启通知权限',
        description: '允许应用发送记账提醒通知',
        icon: Icons.notifications,
        action: () => _notificationService.requestNotificationPermission(),
      ));
    }

    if (!status.isBatteryOptimizationIgnored) {
      steps.add(PermissionStep(
        type: PermissionType.batteryOptimization,
        title: '关闭电池优化',
        description: '确保提醒能够准时送达',
        icon: Icons.battery_saver,
        action: () => _notificationService.requestIgnoreBatteryOptimization(),
      ));
    }

    return await showDialog<bool>(
      context: context,
      barrierDismissible: false,
      builder: (context) => PermissionGuideDialog(steps: steps),
    ) ?? false;
  }
}

class PermissionGuideDialog extends StatefulWidget {
  final List<PermissionStep> steps;

  const PermissionGuideDialog({Key? key, required this.steps}) : super(key: key);

  @override
  State<PermissionGuideDialog> createState() => _PermissionGuideDialogState();
}

class _PermissionGuideDialogState extends State<PermissionGuideDialog> {
  int currentStep = 0;
  Set<int> completedSteps = {};

  @override
  Widget build(BuildContext context) {
    return AlertDialog(
      title: Row(
        children: [
          Icon(Icons.security, color: Theme.of(context).primaryColor),
          const SizedBox(width: 12),
          const Text('权限设置'),
        ],
      ),
      content: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          Text(
            '为了确保记账提醒正常工作,需要您授予以下权限:',
            style: Theme.of(context).textTheme.bodyMedium,
          ),
          const SizedBox(height: 16),

          // 权限步骤列表
          ...widget.steps.asMap().entries.map((entry) {
            final index = entry.key;
            final step = entry.value;
            final isCompleted = completedSteps.contains(index);
            final isCurrent = currentStep == index;

            return _buildPermissionStep(step, index, isCompleted, isCurrent);
          }),

          if (currentStep < widget.steps.length) ...[
            const SizedBox(height: 20),
            Text(
              '当前步骤 ${currentStep + 1}/${widget.steps.length}',
              style: Theme.of(context).textTheme.bodySmall,
            ),
            const SizedBox(height: 8),
            LinearProgressIndicator(
              value: (currentStep + completedSteps.length) / widget.steps.length,
            ),
          ],
        ],
      ),
      actions: [
        if (currentStep < widget.steps.length) ...[
          TextButton(
            onPressed: _skipCurrentStep,
            child: const Text('跳过'),
          ),
          ElevatedButton(
            onPressed: _executeCurrentStep,
            child: Text('去设置'),
          ),
        ] else ...[
          TextButton(
            onPressed: () => Navigator.of(context).pop(false),
            child: const Text('稍后设置'),
          ),
          ElevatedButton(
            onPressed: () => Navigator.of(context).pop(true),
            child: const Text('完成'),
          ),
        ],
      ],
    );
  }

  Widget _buildPermissionStep(
    PermissionStep step,
    int index,
    bool isCompleted,
    bool isCurrent
  ) {
    return Container(
      margin: const EdgeInsets.symmetric(vertical: 8),
      padding: const EdgeInsets.all(12),
      decoration: BoxDecoration(
        color: isCurrent
            ? Theme.of(context).primaryColor.withOpacity(0.1)
            : isCompleted
                ? Colors.green.withOpacity(0.1)
                : Colors.grey.withOpacity(0.05),
        borderRadius: BorderRadius.circular(8),
        border: Border.all(
          color: isCurrent
              ? Theme.of(context).primaryColor
              : isCompleted
                  ? Colors.green
                  : Colors.grey.shade300,
        ),
      ),
      child: Row(
        children: [
          CircleAvatar(
            radius: 20,
            backgroundColor: isCompleted
                ? Colors.green
                : isCurrent
                    ? Theme.of(context).primaryColor
                    : Colors.grey,
            child: Icon(
              isCompleted ? Icons.check : step.icon,
              color: Colors.white,
            ),
          ),
          const SizedBox(width: 16),
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  step.title,
                  style: Theme.of(context).textTheme.titleSmall?.copyWith(
                    fontWeight: isCurrent ? FontWeight.bold : FontWeight.normal,
                  ),
                ),
                const SizedBox(height: 4),
                Text(
                  step.description,
                  style: Theme.of(context).textTheme.bodySmall?.copyWith(
                    color: Colors.grey[600],
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }

  void _executeCurrentStep() async {
    if (currentStep >= widget.steps.length) return;

    final step = widget.steps[currentStep];
    final success = await step.action();

    if (success) {
      setState(() {
        completedSteps.add(currentStep);
        currentStep++;
      });
    } else {
      // 显示错误提示
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('设置${step.title}失败,请手动前往系统设置')),
        );
      }
    }
  }

  void _skipCurrentStep() {
    setState(() {
      currentStep++;
    });
  }
}

提醒配置管理

提醒设置数据模型

dart 复制代码
@JsonSerializable()
class ReminderSettings {
  final int id;
  final bool isEnabled;
  final TimeOfDay time;
  final List<int> weekdays; // 1-7, 1=Monday
  final String title;
  final String message;
  final bool isRepeating;
  final DateTime? nextScheduledTime;

  const ReminderSettings({
    required this.id,
    required this.isEnabled,
    required this.time,
    required this.weekdays,
    required this.title,
    required this.message,
    required this.isRepeating,
    this.nextScheduledTime,
  });

  factory ReminderSettings.fromJson(Map<String, dynamic> json) =>
      _$ReminderSettingsFromJson(json);

  Map<String, dynamic> toJson() => _$ReminderSettingsToJson(this);

  ReminderSettings copyWith({
    int? id,
    bool? isEnabled,
    TimeOfDay? time,
    List<int>? weekdays,
    String? title,
    String? message,
    bool? isRepeating,
    DateTime? nextScheduledTime,
  }) {
    return ReminderSettings(
      id: id ?? this.id,
      isEnabled: isEnabled ?? this.isEnabled,
      time: time ?? this.time,
      weekdays: weekdays ?? this.weekdays,
      title: title ?? this.title,
      message: message ?? this.message,
      isRepeating: isRepeating ?? this.isRepeating,
      nextScheduledTime: nextScheduledTime ?? this.nextScheduledTime,
    );
  }

  /// 计算下一次提醒时间
  DateTime? calculateNextScheduledTime() {
    if (!isEnabled || weekdays.isEmpty) {
      return null;
    }

    final now = DateTime.now();
    final todayWeekday = now.weekday;
    final reminderToday = DateTime(
      now.year,
      now.month,
      now.day,
      time.hour,
      time.minute,
    );

    // 如果今天在提醒日期列表中,且还没过时间,就是今天
    if (weekdays.contains(todayWeekday) && reminderToday.isAfter(now)) {
      return reminderToday;
    }

    // 否则查找下一个提醒日期
    for (int i = 1; i <= 7; i++) {
      final nextDay = now.add(Duration(days: i));
      final nextWeekday = nextDay.weekday;

      if (weekdays.contains(nextWeekday)) {
        return DateTime(
          nextDay.year,
          nextDay.month,
          nextDay.day,
          time.hour,
          time.minute,
        );
      }
    }

    return null;
  }

  /// 是否需要重新调度
  bool needsReschedule() {
    final nextTime = calculateNextScheduledTime();
    return nextTime != nextScheduledTime;
  }
}

提醒管理服务

dart 复制代码
class ReminderManagerService {
  final NotificationService _notificationService;
  final SharedPreferences _prefs;
  static const String _settingsKey = 'reminder_settings';

  ReminderManagerService({
    required NotificationService notificationService,
    required SharedPreferences prefs,
  })  : _notificationService = notificationService,
        _prefs = prefs;

  /// 获取所有提醒设置
  List<ReminderSettings> getAllReminders() {
    final settingsJson = _prefs.getStringList(_settingsKey) ?? [];
    return settingsJson
        .map((json) => ReminderSettings.fromJson(jsonDecode(json)))
        .toList();
  }

  /// 保存提醒设置
  Future<bool> saveReminder(ReminderSettings settings) async {
    try {
      final allSettings = getAllReminders();
      final index = allSettings.indexWhere((s) => s.id == settings.id);

      if (index >= 0) {
        allSettings[index] = settings;
      } else {
        allSettings.add(settings);
      }

      await _saveAllReminders(allSettings);

      // 重新调度通知
      await _scheduleReminder(settings);

      logI('ReminderManager', '✅ 提醒设置已保存: ${settings.title}');
      return true;
    } catch (e) {
      logE('ReminderManager', '❌ 保存提醒设置失败', e);
      return false;
    }
  }

  /// 删除提醒设置
  Future<bool> deleteReminder(int id) async {
    try {
      final allSettings = getAllReminders();
      allSettings.removeWhere((s) => s.id == id);

      await _saveAllReminders(allSettings);
      await _notificationService.cancelNotification(id);

      logI('ReminderManager', '🗑️ 提醒设置已删除: $id');
      return true;
    } catch (e) {
      logE('ReminderManager', '❌ 删除提醒设置失败', e);
      return false;
    }
  }

  /// 启用/禁用提醒
  Future<bool> toggleReminder(int id, bool enabled) async {
    final allSettings = getAllReminders();
    final index = allSettings.indexWhere((s) => s.id == id);

    if (index < 0) return false;

    final updatedSettings = allSettings[index].copyWith(
      isEnabled: enabled,
      nextScheduledTime: enabled ? allSettings[index].calculateNextScheduledTime() : null,
    );

    return await saveReminder(updatedSettings);
  }

  /// 重新调度所有活跃的提醒
  Future<void> rescheduleAllReminders() async {
    final allSettings = getAllReminders().where((s) => s.isEnabled);

    for (final settings in allSettings) {
      await _scheduleReminder(settings);
    }

    logI('ReminderManager', '🔄 已重新调度${allSettings.length}个提醒');
  }

  /// 调度单个提醒
  Future<void> _scheduleReminder(ReminderSettings settings) async {
    if (!settings.isEnabled) {
      await _notificationService.cancelNotification(settings.id);
      return;
    }

    final nextTime = settings.calculateNextScheduledTime();
    if (nextTime == null) {
      logW('ReminderManager', '⚠️ 无法计算下次提醒时间: ${settings.title}');
      return;
    }

    final success = await _notificationService.scheduleNotification(
      id: settings.id,
      title: settings.title,
      body: settings.message,
      scheduledTime: nextTime,
    );

    if (success) {
      // 更新下次调度时间
      final updatedSettings = settings.copyWith(nextScheduledTime: nextTime);
      final allSettings = getAllReminders();
      final index = allSettings.indexWhere((s) => s.id == settings.id);
      if (index >= 0) {
        allSettings[index] = updatedSettings;
        await _saveAllReminders(allSettings);
      }
    }
  }

  Future<void> _saveAllReminders(List<ReminderSettings> settings) async {
    final settingsJson = settings.map((s) => jsonEncode(s.toJson())).toList();
    await _prefs.setStringList(_settingsKey, settingsJson);
  }

  /// 检查并处理过期的提醒
  Future<void> handleExpiredReminders() async {
    final allSettings = getAllReminders();
    bool hasChanges = false;

    for (final settings in allSettings) {
      if (settings.isEnabled && settings.needsReschedule()) {
        await _scheduleReminder(settings);
        hasChanges = true;
      }
    }

    if (hasChanges) {
      logI('ReminderManager', '🔄 已处理过期的提醒设置');
    }
  }
}

用户界面设计

提醒设置页面

dart 复制代码
class ReminderSettingsPage extends ConsumerStatefulWidget {
  @override
  ConsumerState<ReminderSettingsPage> createState() => _ReminderSettingsPageState();
}

class _ReminderSettingsPageState extends ConsumerState<ReminderSettingsPage> {
  @override
  Widget build(BuildContext context) {
    final reminders = ref.watch(reminderManagerProvider).getAllReminders();
    final permissionStatus = ref.watch(permissionStatusProvider);

    return Scaffold(
      appBar: AppBar(
        title: const Text('记账提醒'),
        actions: [
          IconButton(
            icon: const Icon(Icons.add),
            onPressed: _addNewReminder,
          ),
        ],
      ),
      body: Column(
        children: [
          // 权限状态卡片
          _buildPermissionStatusCard(permissionStatus),

          // 提醒列表
          Expanded(
            child: reminders.isEmpty
                ? _buildEmptyState()
                : ListView.builder(
                    itemCount: reminders.length,
                    itemBuilder: (context, index) {
                      return _buildReminderItem(reminders[index]);
                    },
                  ),
          ),
        ],
      ),
    );
  }

  Widget _buildPermissionStatusCard(AsyncValue<PermissionStatus> statusAsync) {
    return statusAsync.when(
      data: (status) {
        if (status.isAllGranted) {
          return Card(
            color: Colors.green.shade50,
            child: ListTile(
              leading: CircleAvatar(
                backgroundColor: Colors.green,
                child: Icon(Icons.check, color: Colors.white),
              ),
              title: Text('权限设置完成'),
              subtitle: Text('提醒功能可以正常使用'),
              trailing: Icon(Icons.notifications_active, color: Colors.green),
            ),
          );
        } else {
          return Card(
            color: Colors.orange.shade50,
            child: ListTile(
              leading: CircleAvatar(
                backgroundColor: Colors.orange,
                child: Icon(Icons.warning, color: Colors.white),
              ),
              title: Text('需要完成权限设置'),
              subtitle: Text('某些权限未授予,可能影响提醒功能'),
              trailing: TextButton(
                onPressed: _openPermissionGuide,
                child: Text('去设置'),
              ),
            ),
          );
        }
      },
      loading: () => Card(
        child: ListTile(
          leading: CircularProgressIndicator(),
          title: Text('检查权限状态中...'),
        ),
      ),
      error: (error, _) => Card(
        color: Colors.red.shade50,
        child: ListTile(
          leading: CircleAvatar(
            backgroundColor: Colors.red,
            child: Icon(Icons.error, color: Colors.white),
          ),
          title: Text('权限检查失败'),
          subtitle: Text('请手动检查应用权限设置'),
        ),
      ),
    );
  }

  Widget _buildReminderItem(ReminderSettings reminder) {
    return Card(
      child: ListTile(
        leading: CircleAvatar(
          backgroundColor: reminder.isEnabled
              ? Theme.of(context).primaryColor
              : Colors.grey,
          child: Icon(
            Icons.alarm,
            color: Colors.white,
          ),
        ),
        title: Text(reminder.title),
        subtitle: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(reminder.message),
            const SizedBox(height: 4),
            Text(
              '${_formatTime(reminder.time)} • ${_formatWeekdays(reminder.weekdays)}',
              style: TextStyle(
                fontSize: 12,
                color: Colors.grey[600],
              ),
            ),
            if (reminder.nextScheduledTime != null) ...[
              const SizedBox(height: 2),
              Text(
                '下次提醒: ${_formatDateTime(reminder.nextScheduledTime!)}',
                style: TextStyle(
                  fontSize: 11,
                  color: Colors.blue[600],
                ),
              ),
            ],
          ],
        ),
        trailing: Row(
          mainAxisSize: MainAxisSize.min,
          children: [
            Switch(
              value: reminder.isEnabled,
              onChanged: (enabled) => _toggleReminder(reminder.id, enabled),
            ),
            PopupMenuButton<String>(
              onSelected: (value) => _handleReminderAction(reminder, value),
              itemBuilder: (context) => [
                PopupMenuItem(value: 'edit', child: Text('编辑')),
                PopupMenuItem(value: 'delete', child: Text('删除')),
              ],
            ),
          ],
        ),
        isThreeLine: true,
      ),
    );
  }

  Widget _buildEmptyState() {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Icon(
            Icons.alarm_off,
            size: 80,
            color: Colors.grey[400],
          ),
          const SizedBox(height: 16),
          Text(
            '还没有设置任何提醒',
            style: Theme.of(context).textTheme.headlineSmall?.copyWith(
              color: Colors.grey[600],
            ),
          ),
          const SizedBox(height: 8),
          Text(
            '点击右上角的 + 号添加第一个记账提醒',
            style: Theme.of(context).textTheme.bodyMedium?.copyWith(
              color: Colors.grey[500],
            ),
          ),
          const SizedBox(height: 24),
          ElevatedButton.icon(
            onPressed: _addNewReminder,
            icon: Icon(Icons.add),
            label: Text('添加提醒'),
          ),
        ],
      ),
    );
  }

  String _formatTime(TimeOfDay time) {
    final hour = time.hour.toString().padLeft(2, '0');
    final minute = time.minute.toString().padLeft(2, '0');
    return '$hour:$minute';
  }

  String _formatWeekdays(List<int> weekdays) {
    if (weekdays.length == 7) return '每日';
    if (weekdays.length == 5 && weekdays.every((w) => w >= 1 && w <= 5)) {
      return '工作日';
    }
    if (weekdays.length == 2 && weekdays.contains(6) && weekdays.contains(7)) {
      return '周末';
    }

    const weekdayNames = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日'];
    return weekdays.map((w) => weekdayNames[w]).join('、');
  }

  String _formatDateTime(DateTime dateTime) {
    final now = DateTime.now();
    final today = DateTime(now.year, now.month, now.day);
    final targetDate = DateTime(dateTime.year, dateTime.month, dateTime.day);

    if (targetDate == today) {
      return '今天 ${_formatTime(TimeOfDay.fromDateTime(dateTime))}';
    } else if (targetDate == today.add(Duration(days: 1))) {
      return '明天 ${_formatTime(TimeOfDay.fromDateTime(dateTime))}';
    } else {
      return '${dateTime.month}/${dateTime.day} ${_formatTime(TimeOfDay.fromDateTime(dateTime))}';
    }
  }

  void _addNewReminder() {
    Navigator.of(context).push(
      MaterialPageRoute(
        builder: (context) => ReminderEditPage(),
      ),
    );
  }

  void _toggleReminder(int id, bool enabled) {
    ref.read(reminderManagerProvider).toggleReminder(id, enabled);
  }

  void _handleReminderAction(ReminderSettings reminder, String action) {
    switch (action) {
      case 'edit':
        Navigator.of(context).push(
          MaterialPageRoute(
            builder: (context) => ReminderEditPage(reminder: reminder),
          ),
        );
        break;
      case 'delete':
        _deleteReminder(reminder);
        break;
    }
  }

  void _deleteReminder(ReminderSettings reminder) async {
    final confirmed = await showDialog<bool>(
      context: context,
      builder: (context) => AlertDialog(
        title: Text('删除提醒'),
        content: Text('确定要删除「${reminder.title}」提醒吗?'),
        actions: [
          TextButton(
            onPressed: () => Navigator.of(context).pop(false),
            child: Text('取消'),
          ),
          TextButton(
            onPressed: () => Navigator.of(context).pop(true),
            child: Text('删除'),
          ),
        ],
      ),
    );

    if (confirmed == true) {
      await ref.read(reminderManagerProvider).deleteReminder(reminder.id);
    }
  }

  void _openPermissionGuide() async {
    final permissionGuide = ref.read(permissionGuideServiceProvider);
    await permissionGuide.guideUserThroughPermissions(context);

    // 重新检查权限状态
    ref.refresh(permissionStatusProvider);
  }
}

性能优化和最佳实践

电池优化适配

dart 复制代码
class BatteryOptimizationHelper {
  /// 检查不同厂商的电池优化设置
  static Future<BatteryOptimizationInfo> getBatteryOptimizationInfo() async {
    final info = await _channel.invokeMethod('getBatteryOptimizationInfo');
    return BatteryOptimizationInfo.fromMap(info);
  }

  /// 提供厂商特定的设置指引
  static String getManufacturerSpecificGuide(String manufacturer) {
    final lowerManufacturer = manufacturer.toLowerCase();

    switch (lowerManufacturer) {
      case 'xiaomi':
        return '''
小米设备设置指南:
1. 进入「设置」→「电池与性能」→「省电优化」
2. 找到「蜜蜂记账」→选择「无限制」
3. 进入「设置」→「通知管理」→「蜜蜂记账」
4. 开启「通知管理」和「锁屏通知」
''';

      case 'huawei':
      case 'honor':
        return '''
华为/荣耀设备设置指南:
1. 进入「设置」→「电池」→「启动管理」
2. 找到「蜜蜂记账」→开启「手动管理」
3. 允许「自启动」、「关联启动」、「后台活动」
4. 进入「设置」→「通知」→「蜜蜂记账」→开启通知
''';

      case 'oppo':
        return '''
OPPO设备设置指南:
1. 进入「设置」→「电池」→「省电模式」
2. 找到「蜜蜂记账」→选择「智能后台冻结:关」
3. 进入「设置」→「应用管理」→「蜜蜂记账」
4. 开启「允许关联启动」和「允许后台活动」
''';

      case 'vivo':
        return '''
VIVO设备设置指南:
1. 进入「设置」→「电池」→「后台高耗电」
2. 找到「蜜蜂记账」→选择「允许后台高耗电」
3. 进入「设置」→「应用与权限」→「蜜蜂记账」
4. 开启「自启动」和「允许关联启动」
''';

      default:
        return '''
原生Android设置指南:
1. 进入「设置」→「电池」→「电池优化」
2. 找到「蜜蜂记账」→选择「不优化」
3. 确保通知权限已开启
''';
    }
  }
}

class BatteryOptimizationInfo {
  final bool isIgnoring;
  final bool canRequest;
  final String manufacturer;
  final String model;
  final String androidVersion;

  BatteryOptimizationInfo({
    required this.isIgnoring,
    required this.canRequest,
    required this.manufacturer,
    required this.model,
    required this.androidVersion,
  });

  factory BatteryOptimizationInfo.fromMap(Map<String, dynamic> map) {
    return BatteryOptimizationInfo(
      isIgnoring: map['isIgnoring'] ?? false,
      canRequest: map['canRequest'] ?? false,
      manufacturer: map['manufacturer'] ?? '',
      model: map['model'] ?? '',
      androidVersion: map['androidVersion'] ?? '',
    );
  }

  String get deviceInfo => '$manufacturer $model (Android $androidVersion)';

  bool get needsManualSetup {
    final problematicManufacturers = ['xiaomi', 'huawei', 'honor', 'oppo', 'vivo'];
    return problematicManufacturers.contains(manufacturer.toLowerCase());
  }
}

通知调试工具

dart 复制代码
class NotificationDebugService {
  static const String _debugLogKey = 'notification_debug_log';
  final SharedPreferences _prefs;

  NotificationDebugService(this._prefs);

  /// 记录通知调试日志
  void logNotificationEvent(String event, Map<String, dynamic> data) {
    final logEntry = {
      'timestamp': DateTime.now().toIso8601String(),
      'event': event,
      'data': data,
    };

    final logs = getDebugLogs();
    logs.add(logEntry);

    // 只保留最近100条记录
    if (logs.length > 100) {
      logs.removeAt(0);
    }

    _saveLogs(logs);
  }

  /// 获取调试日志
  List<Map<String, dynamic>> getDebugLogs() {
    final logsJson = _prefs.getStringList(_debugLogKey) ?? [];
    return logsJson.map((log) => jsonDecode(log) as Map<String, dynamic>).toList();
  }

  /// 清除调试日志
  Future<void> clearDebugLogs() async {
    await _prefs.remove(_debugLogKey);
  }

  /// 导出调试日志
  String exportDebugLogs() {
    final logs = getDebugLogs();
    final buffer = StringBuffer();

    buffer.writeln('=== BeeCount 通知调试日志 ===');
    buffer.writeln('导出时间: ${DateTime.now()}');
    buffer.writeln('日志条数: ${logs.length}');
    buffer.writeln('');

    for (final log in logs) {
      buffer.writeln('[${log['timestamp']}] ${log['event']}');
      if (log['data'].isNotEmpty) {
        log['data'].forEach((key, value) {
          buffer.writeln('  $key: $value');
        });
      }
      buffer.writeln('');
    }

    return buffer.toString();
  }

  void _saveLogs(List<Map<String, dynamic>> logs) {
    final logsJson = logs.map((log) => jsonEncode(log)).toList();
    _prefs.setStringList(_debugLogKey, logsJson);
  }

  /// 测试通知功能
  Future<NotificationTestResult> testNotification() async {
    final result = NotificationTestResult();

    try {
      // 1. 检查权限
      final hasPermission = await NotificationService.instance.hasNotificationPermission();
      result.addTest('权限检查', hasPermission, hasPermission ? '有通知权限' : '缺少通知权限');

      // 2. 检查电池优化
      final isBatteryIgnored = await NotificationService.instance.isBatteryOptimizationIgnored();
      result.addTest('电池优化', isBatteryIgnored, isBatteryIgnored ? '已忽略电池优化' : '受电池优化影响');

      // 3. 测试即时通知
      final immediateSuccess = await NotificationService.instance.scheduleNotification(
        id: 99999,
        title: '测试通知',
        body: '这是一条测试通知,用于验证通知功能是否正常',
        scheduledTime: DateTime.now().add(Duration(seconds: 2)),
      );
      result.addTest('即时通知', immediateSuccess, immediateSuccess ? '通知已调度' : '通知调度失败');

      // 4. 测试延迟通知
      final delayedSuccess = await NotificationService.instance.scheduleNotification(
        id: 99998,
        title: '延迟测试通知',
        body: '这是一条延迟测试通知,应该在30秒后显示',
        scheduledTime: DateTime.now().add(Duration(seconds: 30)),
      );
      result.addTest('延迟通知', delayedSuccess, delayedSuccess ? '延迟通知已调度' : '延迟通知调度失败');

    } catch (e) {
      result.addTest('测试异常', false, e.toString());
    }

    return result;
  }
}

class NotificationTestResult {
  final List<TestItem> tests = [];

  void addTest(String name, bool success, String message) {
    tests.add(TestItem(name: name, success: success, message: message));
  }

  bool get allPassed => tests.every((test) => test.success);
  int get passedCount => tests.where((test) => test.success).length;
  int get totalCount => tests.length;

  String get summary => '$passedCount/$totalCount 项测试通过';
}

class TestItem {
  final String name;
  final bool success;
  final String message;

  TestItem({required this.name, required this.success, required this.message});
}

实际应用效果

在BeeCount项目中,完善的通知提醒系统带来了显著的用户价值:

  1. 用户粘性提升:定时提醒帮助用户养成记账习惯,应用日活跃度提升35%
  2. 跨设备兼容性:适配主流Android厂商的电池优化策略,通知送达率达95%+
  3. 用户体验优化:智能权限引导减少了用户配置困扰,设置完成率提升60%
  4. 系统资源优化:精确的AlarmManager调度和合理的权限管理,避免了过度耗电

结语

构建可靠的移动应用通知系统需要深入理解Android系统特性,合理处理各种权限和优化策略。通过系统化的架构设计、完善的权限管理和细致的用户体验优化,我们可以在系统限制下为用户提供准时可靠的提醒服务。

BeeCount的通知系统实践证明,技术实现与用户体验的平衡是移动应用成功的关键。这套方案不仅适用于记账类应用,对任何需要定时提醒功能的应用都具有重要的参考价值。

关于BeeCount项目

项目特色

  • 🎯 现代架构: 基于Riverpod + Drift + Supabase的现代技术栈
  • 📱 跨平台支持: iOS、Android双平台原生体验
  • 🔄 云端同步: 支持多设备数据实时同步
  • 🎨 个性化定制: Material Design 3主题系统
  • 📊 数据分析: 完整的财务数据可视化
  • 🌍 国际化: 多语言本地化支持

技术栈一览

  • 框架: Flutter 3.6.1+ / Dart 3.6.1+
  • 状态管理: Flutter Riverpod 2.5.1
  • 数据库: Drift (SQLite) 2.20.2
  • 云服务: Supabase 2.5.6
  • 图表: FL Chart 0.68.0
  • CI/CD: GitHub Actions

开源信息

BeeCount是一个完全开源的项目,欢迎开发者参与贡献:

参考资源

官方文档

学习资源


本文是BeeCount技术文章系列的第4篇,后续将深入探讨主题系统、数据可视化等话题。如果你觉得这篇文章有帮助,欢迎关注项目并给个Star!

相关推荐
RoyLin2 小时前
TypeScript设计模式:仲裁者模式
前端·后端·typescript
子兮曰2 小时前
🚀前端依赖配置避坑指南:深度解析package.json中devDependencies的常见误解
前端·javascript·npm
瑶琴AI前端2 小时前
【零成本高效编程】VS Code必装的5款免费AI插件,开发效率飙升!
前端·ai编程·visual studio code
forever_Mamba2 小时前
实现一个高性能倒计时:从踩坑到最佳实践
前端·javascript
_AaronWong2 小时前
实现一个鼠标滚轮横向滚动需求
前端·electron
子兮曰2 小时前
浏览器与 Node.js 全局变量体系详解:从 window 到 global 的核心差异
前端·javascript·node.js
Olrookie2 小时前
ruoyi-vue(十五)——布局设置,导航栏,侧边栏,顶部栏
前端·vue.js·笔记
召摇2 小时前
API 设计最佳实践 Javascript 篇
前端·javascript·vue.js
光影少年2 小时前
vite打包优化有哪些
前端·vite·掘金·金石计划