This commit is contained in:
Developer
2026-06-02 03:52:54 +08:00
parent 1cb9bc8649
commit 10df6b705c
38 changed files with 2285 additions and 167 deletions

View File

@@ -1,9 +1,9 @@
/// ============================================================
/// 闲言APP — 灵动岛/实时活动Provider
/// 创建时间: 2026-05-25
/// 更新时间: 2026-05-30
/// 更新时间: 2026-06-02
/// 作用: 灵动岛服务状态管理Provider
/// 上次更新: 使用SafeNotifierInit统一异常保护
/// 上次更新: 补充倒计时活动update/end方法
/// ============================================================
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -107,6 +107,25 @@ class LiveActivityNotifier extends Notifier<LiveActivityState>
);
}
/// 更新倒计时进度
Future<void> updateCountdownActivity({
required String title,
required int remainingMinutes,
String emoji = '⏱️',
}) async {
await LiveActivityService.instance.updateCountdownActivity(
title: title,
remainingMinutes: remainingMinutes,
emoji: emoji,
);
}
/// 结束倒计时活动
Future<void> endCountdownActivity() async {
await LiveActivityService.instance.endCountdownActivity();
state = state.copyWith(hasActiveActivity: false);
}
/// 结束所有活动
Future<void> endAllActivities() async {
await LiveActivityService.instance.endAllActivities();

View File

@@ -1,9 +1,9 @@
/// ============================================================
/// 闲言APP — 灵动岛/实时活动服务
/// 创建时间: 2026-05-25
/// 更新时间: 2026-05-25
/// 作用: 基于live_activities实现番茄钟灵动岛显示
/// 上次更新: 初始创建
/// 更新时间: 2026-06-02
/// 作用: 基于live_activities实现番茄钟/倒计时灵动岛显示
/// 上次更新: 增强倒计时活动支持updateCountdownActivity/endCountdownActivity
/// ============================================================
import 'package:flutter/foundation.dart';
@@ -12,10 +12,6 @@ import 'package:live_activities/live_activities.dart';
import '../../utils/logger.dart';
import '../device/haptic_service.dart';
/// 灵动岛/实时活动服务 — 全局单例
///
/// 仅iOS 16.1+平台有效,其他平台静默返回。
/// 支持番茄钟倒计时和通用倒计时两种活动类型。
class LiveActivityService {
LiveActivityService._();
@@ -25,21 +21,16 @@ class LiveActivityService {
bool _isSupported = false;
String? _activeActivityId;
String? _activeType;
/// 是否支持灵动岛iOS 16.1+
bool get isSupported => _isSupported;
/// 当前活动ID
String? get activeActivityId => _activeActivityId;
/// 是否有活跃的活动
bool get hasActiveActivity => _activeActivityId != null;
String? get activeType => _activeType;
/// 活动类型标识 — 对应iOS Widget Extension中的ActivityType
static const _pomodoroScheme = 'pomodoro';
static const _countdownScheme = 'countdown';
/// 初始化并检测支持
Future<void> init() async {
if (defaultTargetPlatform != TargetPlatform.iOS) {
_isSupported = false;
@@ -56,11 +47,10 @@ class LiveActivityService {
}
}
/// 开始番茄钟活动
///
/// [remainingMinutes] 剩余分钟数
/// [totalMinutes] 总分钟数
/// [isBreak] 是否为休息阶段
// ============================================================
// 番茄钟活动
// ============================================================
Future<void> startPomodoroActivity({
required int remainingMinutes,
required int totalMinutes,
@@ -69,6 +59,8 @@ class LiveActivityService {
if (!_isSupported) return;
try {
await _endExistingIfDifferent('pomodoro');
final now = DateTime.now();
final endTime = now.add(Duration(minutes: remainingMinutes));
@@ -87,6 +79,7 @@ class LiveActivityService {
removeWhenAppIsKilled: true,
);
_activeType = 'pomodoro';
HapticService.medium();
Log.i('LiveActivityService: 番茄钟活动已创建 id=$_activeActivityId');
} catch (e) {
@@ -94,11 +87,6 @@ class LiveActivityService {
}
}
/// 更新番茄钟进度
///
/// [remainingMinutes] 剩余分钟数
/// [totalMinutes] 总分钟数
/// [isBreak] 是否为休息阶段
Future<void> updatePomodoroActivity({
required int remainingMinutes,
required int totalMinutes,
@@ -130,27 +118,26 @@ class LiveActivityService {
}
}
/// 结束番茄钟活动
Future<void> endPomodoroActivity() async {
if (!_isSupported || _activeActivityId == null) return;
if (!_isSupported || _activeActivityId == null || _activeType != 'pomodoro') return;
try {
await _liveActivities.endActivity(_activeActivityId!);
HapticService.success();
Log.i('LiveActivityService: 番茄钟活动已结束');
_activeActivityId = null;
_activeType = null;
} catch (e) {
Log.e('LiveActivityService: 结束番茄钟活动失败 $e');
_activeActivityId = null;
_activeType = null;
}
}
/// 开始倒计时活动
///
/// [title] 倒计时标题
/// [remainingMinutes] 剩余分钟数
/// [emoji] 显示emoji
// ============================================================
// 倒计时活动
// ============================================================
Future<void> startCountdownActivity({
required String title,
required int remainingMinutes,
@@ -159,6 +146,8 @@ class LiveActivityService {
if (!_isSupported) return;
try {
await _endExistingIfDifferent('countdown');
final now = DateTime.now();
final endTime = now.add(Duration(minutes: remainingMinutes));
@@ -174,6 +163,7 @@ class LiveActivityService {
removeWhenAppIsKilled: true,
);
_activeType = 'countdown';
HapticService.medium();
Log.i('LiveActivityService: 倒计时活动已创建 id=$_activeActivityId');
} catch (e) {
@@ -181,21 +171,82 @@ class LiveActivityService {
}
}
/// 结束所有活动
Future<void> updateCountdownActivity({
required String title,
required int remainingMinutes,
String emoji = '⏱️',
}) async {
if (!_isSupported || _activeActivityId == null || _activeType != 'countdown') return;
try {
final now = DateTime.now();
final endTime = now.add(Duration(minutes: remainingMinutes));
await _liveActivities.updateActivity(
_activeActivityId!,
<String, dynamic>{
'type': 'countdown',
'title': title,
'remainingMinutes': remainingMinutes,
'endTime': endTime.millisecondsSinceEpoch,
'emoji': emoji,
},
);
Log.d('LiveActivityService: 倒计时活动已更新 remaining=$remainingMinutes');
} catch (e) {
Log.e('LiveActivityService: 更新倒计时活动失败 $e');
}
}
Future<void> endCountdownActivity() async {
if (!_isSupported || _activeActivityId == null || _activeType != 'countdown') return;
try {
await _liveActivities.endActivity(_activeActivityId!);
HapticService.success();
Log.i('LiveActivityService: 倒计时活动已结束');
_activeActivityId = null;
_activeType = null;
} catch (e) {
Log.e('LiveActivityService: 结束倒计时活动失败 $e');
_activeActivityId = null;
_activeType = null;
}
}
// ============================================================
// 通用方法
// ============================================================
Future<void> endAllActivities() async {
if (!_isSupported) return;
try {
await _liveActivities.endAllActivities();
_activeActivityId = null;
_activeType = null;
Log.i('LiveActivityService: 所有活动已结束');
} catch (e) {
Log.e('LiveActivityService: 结束所有活动失败 $e');
_activeActivityId = null;
_activeType = null;
}
}
Future<void> _endExistingIfDifferent(String newType) async {
if (_activeActivityId != null && _activeType != null && _activeType != newType) {
try {
await _liveActivities.endActivity(_activeActivityId!);
_activeActivityId = null;
_activeType = null;
Log.i('LiveActivityService: 结束旧活动(类型不同)');
} catch (e) {
Log.w('LiveActivityService: 结束旧活动失败 $e');
}
}
}
/// 释放资源
void dispose() {
_liveActivities.dispose();
}

View File

@@ -0,0 +1,120 @@
/// ============================================================
/// 闲言APP — macOS平台统一服务
/// 创建时间: 2026-06-02
/// 更新时间: 2026-06-02
/// 作用: 集中管理所有macOS原生MethodChannel交互主题同步/窗口管理/工具栏样式)
/// 上次更新: 整合MacosTitleBarService新增窗口管理能力
/// ============================================================
import 'package:flutter/services.dart';
import 'package:xianyan/core/utils/platform/platform_utils.dart' as pu;
import 'package:xianyan/core/utils/logger.dart';
class MacosPlatformService {
MacosPlatformService._();
static const _channel = MethodChannel('com.xianyan.macos');
// ============================================================
// 主题同步(原 MacosTitleBarService
// ============================================================
static bool _lastIsDark = false;
static bool _themeInitialized = false;
/// 同步标题栏明暗模式
static void syncTheme(bool isDark) {
if (!pu.isMacOS) return;
if (_themeInitialized && _lastIsDark == isDark) return;
_themeInitialized = true;
_lastIsDark = isDark;
_invoke('setDarkMode', isDark);
}
// ============================================================
// 窗口管理
// ============================================================
/// 设置窗口标题
static void setWindowTitle(String title) {
if (!pu.isMacOS) return;
_invoke('setWindowTitle', title);
}
/// 设置窗口透明标题栏
static void setTitleBarTransparent(bool transparent) {
if (!pu.isMacOS) return;
_invoke('setTitleBarTransparent', transparent);
}
/// 设置标题栏样式auto/light/dark
static void setTitleBarStyle(String style) {
if (!pu.isMacOS) return;
_invoke('setTitleBarStyle', style);
}
/// 设置工具栏可见性
static void setToolbarVisible(bool visible) {
if (!pu.isMacOS) return;
_invoke('setToolbarVisible', visible);
}
/// 设置窗口全屏
static void setFullscreen(bool fullscreen) {
if (!pu.isMacOS) return;
_invoke('setFullscreen', fullscreen);
}
/// 获取窗口是否全屏
static Future<bool> isFullscreen() async {
if (!pu.isMacOS) return false;
try {
final result = await _channel.invokeMethod<bool>('isFullscreen');
return result ?? false;
} catch (e) {
Log.w('MacosPlatformService.isFullscreen失败: $e');
return false;
}
}
/// 设置窗口最小尺寸
static void setMinSize(double width, double height) {
if (!pu.isMacOS) return;
_invoke('setMinSize', {'width': width, 'height': height});
}
// ============================================================
// 系统集成
// ============================================================
/// 通知系统触感反馈
static void performHapticFeedback(String type) {
if (!pu.isMacOS) return;
_invoke('performHapticFeedback', type);
}
/// 获取系统外观light/dark
static Future<String?> getSystemAppearance() async {
if (!pu.isMacOS) return null;
try {
return await _channel.invokeMethod<String>('getSystemAppearance');
} catch (e) {
Log.w('MacosPlatformService.getSystemAppearance失败: $e');
return null;
}
}
// ============================================================
// 内部工具
// ============================================================
static void _invoke(String method, [dynamic arguments]) {
try {
_channel.invokeMethod<void>(method, arguments);
} catch (e) {
Log.w('MacosPlatformService.$method失败: $e');
}
}
}

View File

@@ -54,7 +54,7 @@ InitializationSettings _buildOhosInitSettings({
final dynamic ohosSettings = _createOhosInitializationSettings(
ohosDefaultIcon,
);
final dynamic constructor = InitializationSettings.new;
const dynamic constructor = InitializationSettings.new;
return constructor(
android: androidSettings,
iOS: iosSettings,
@@ -75,7 +75,7 @@ InitializationSettings _buildOhosInitSettings({
/// 鸿蒙端本地包导出 OhosInitializationSettings 类,
/// 官方 SDK 不存在此类。通过 dynamic 反射创建实例。
dynamic _createOhosInitializationSettings(String defaultIcon) {
final dynamic plugin = FlutterLocalNotificationsPlugin;
const dynamic plugin = FlutterLocalNotificationsPlugin;
final dynamic ohosClass = plugin.ohosInitializationSettings;
return ohosClass(defaultIcon);
}

View File

@@ -0,0 +1,12 @@
/// ============================================================
/// 闲言APP — macOS标题栏主题同步服务已废弃
/// 创建时间: 2026-06-02
/// 更新时间: 2026-06-02
/// 作用: 已迁移至 MacosPlatformService此文件仅做兼容桥接
/// 上次更新: 废弃,所有功能已迁移至 macos_platform_service.dart
/// ============================================================
@Deprecated('已迁移至 MacosPlatformService请使用 MacosPlatformService.syncTheme()')
library;
export 'package:xianyan/core/services/device/macos_platform_service.dart';

View File

@@ -0,0 +1,187 @@
/// ============================================================
/// 闲言APP — 二维码登录WebSocket推送服务
/// 创建时间: 2026-06-02
/// 更新时间: 2026-06-02
/// 作用: 通过WebSocket长连接接收二维码状态变更推送替代HTTP轮询
/// 上次更新: 初始创建,支持信令服务器订阅+HTTP轮询降级
/// ============================================================
import 'dart:async';
import 'dart:convert';
import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:xianyan/core/utils/logger.dart';
typedef QrcodeStatusCallback = void Function(Map<String, dynamic> data);
class QrcodeWsService {
QrcodeWsService._();
static final QrcodeWsService instance = QrcodeWsService._();
WebSocketChannel? _channel;
bool _isConnected = false;
Timer? _heartbeatTimer;
Timer? _reconnectTimer;
int _reconnectAttempts = 0;
static const _maxReconnectAttempts = 5;
String? _subscribedCode;
QrcodeStatusCallback? _onStatusUpdate;
StreamSubscription? _subscription;
bool get isConnected => _isConnected;
/// 连接WebSocket服务器
Future<bool> connect() async {
if (_isConnected) return true;
final wsUrl = _resolveWsUrl();
if (wsUrl.isEmpty) {
Log.w('QrcodeWsService: WebSocket URL不可用');
return false;
}
try {
Log.i('QrcodeWsService: 连接 $wsUrl');
_channel = WebSocketChannel.connect(Uri.parse(wsUrl));
await _channel!.ready.timeout(
const Duration(seconds: 5),
onTimeout: () => throw TimeoutException('连接超时'),
);
_isConnected = true;
_reconnectAttempts = 0;
_startHeartbeat();
_subscription = _channel!.stream.listen(
(data) => _handleMessage(data),
onDone: () {
_isConnected = false;
Log.w('QrcodeWsService: 连接关闭');
_scheduleReconnect();
},
onError: (e) {
_isConnected = false;
Log.e('QrcodeWsService: 连接错误 $e');
_scheduleReconnect();
},
);
Log.i('QrcodeWsService: 连接成功');
if (_subscribedCode != null) {
_sendSubscribe(_subscribedCode!);
}
return true;
} catch (e) {
Log.w('QrcodeWsService: 连接失败 $e');
_isConnected = false;
return false;
}
}
/// 订阅二维码状态变更
Future<void> subscribe(String code, QrcodeStatusCallback onStatus) async {
_subscribedCode = code;
_onStatusUpdate = onStatus;
if (!_isConnected) {
final ok = await connect();
if (!ok) {
Log.w('QrcodeWsService: WebSocket不可用将使用HTTP轮询降级');
return;
}
}
_sendSubscribe(code);
}
/// 取消订阅
void unsubscribe() {
if (_isConnected && _subscribedCode != null) {
_send({'type': 'qrcode_unsubscribe', 'code': _subscribedCode});
}
_subscribedCode = null;
_onStatusUpdate = null;
}
/// 断开连接
void disconnect() {
_subscription?.cancel();
_subscription = null;
_heartbeatTimer?.cancel();
_reconnectTimer?.cancel();
_channel?.sink.close();
_channel = null;
_isConnected = false;
_subscribedCode = null;
_onStatusUpdate = null;
_reconnectAttempts = 0;
Log.i('QrcodeWsService: 已断开');
}
void _sendSubscribe(String code) {
_send({
'type': 'qrcode_subscribe',
'code': code,
});
Log.i('QrcodeWsService: 已订阅 $code');
}
void _send(Map<String, dynamic> data) {
if (!_isConnected || _channel == null) return;
try {
_channel!.sink.add(jsonEncode(data));
} catch (e) {
Log.w('QrcodeWsService: 发送失败 $e');
}
}
void _handleMessage(dynamic data) {
try {
final json = jsonDecode(data as String) as Map<String, dynamic>;
final type = json['type'] as String? ?? '';
if (type == 'qrcode_status_update') {
Log.i('QrcodeWsService: 收到状态推送 ${json['status']}');
_onStatusUpdate?.call(json);
} else if (type == 'pong') {
// heartbeat ack
}
} catch (e) {
Log.w('QrcodeWsService: 消息解析失败 $e');
}
}
void _startHeartbeat() {
_heartbeatTimer?.cancel();
_heartbeatTimer = Timer.periodic(const Duration(seconds: 25), (_) {
_send({'type': 'ping'});
});
}
void _scheduleReconnect() {
if (_reconnectAttempts >= _maxReconnectAttempts) {
Log.w('QrcodeWsService: 超过最大重连次数');
return;
}
if (_subscribedCode == null) return;
_reconnectAttempts++;
final delay = Duration(seconds: _reconnectAttempts * 2);
Log.i('QrcodeWsService: ${delay.inSeconds}秒后重连 (第$_reconnectAttempts次)');
_reconnectTimer?.cancel();
_reconnectTimer = Timer(delay, () async {
final ok = await connect();
if (ok && _subscribedCode != null) {
_sendSubscribe(_subscribedCode!);
}
});
}
String _resolveWsUrl() {
return 'wss://tools.wktyl.com:9443';
}
}

View File

@@ -1,15 +1,16 @@
/// ============================================================
/// 闲言APP — 倒计时页面
/// 创建时间: 2026-05-02
/// 更新时间: 2026-05-30
/// 作用: 倒计时事件列表 + 新增/编辑 + 分类筛选
/// 上次更新: 修复+号按钮颜色和点击区域; iconPrimary→accent, 增加minimumSize
/// 更新时间: 2026-06-02
/// 作用: 倒计时事件列表 + 新增/编辑 + 分类筛选 + 灵动岛聚焦
/// 上次更新: 集成灵动岛聚焦模式,卡片添加灵动岛按钮
/// ============================================================
import 'package:flutter/cupertino.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/services/device/live_activity_provider.dart';
import '../../../core/theme/app_radius.dart';
import '../../../core/theme/app_spacing.dart';
import '../../../core/theme/app_theme.dart';
@@ -95,6 +96,7 @@ class _CountdownPageState extends ConsumerState<CountdownPage> {
vertical: 12,
),
children: [
if (state.focusedEventId != null) _buildFocusBanner(state, ext),
if (state.pinned.isNotEmpty) ...[
_buildSectionTitle('📌 置顶', ext),
...state.pinned.asMap().entries.map(
@@ -120,6 +122,65 @@ class _CountdownPageState extends ConsumerState<CountdownPage> {
);
}
// ============================================================
// 灵动岛聚焦横幅
// ============================================================
Widget _buildFocusBanner(CountdownState state, AppThemeExtension ext) {
final event = state.focusedEvent;
if (event == null) return const SizedBox.shrink();
return Container(
margin: const EdgeInsets.only(bottom: AppSpacing.md),
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm + 2,
),
decoration: BoxDecoration(
color: ext.accent.withValues(alpha: 0.1),
borderRadius: AppRadius.lgBorder,
border: Border.all(color: ext.accent.withValues(alpha: 0.25)),
),
child: Row(
children: [
Text(event.emoji, style: const TextStyle(fontSize: 18)),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'灵动岛已开启',
style: AppTypography.caption1.copyWith(
color: ext.accent,
fontWeight: FontWeight.w600,
),
),
Text(
'${event.title} · ${event.remainingLabel}',
style: AppTypography.caption2.copyWith(
color: ext.textSecondary,
),
),
],
),
),
CupertinoButton(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
minimumSize: Size.zero,
onPressed: () =>
ref.read(countdownProvider.notifier).unfocusEvent(),
child: Icon(
CupertinoIcons.xmark_circle_fill,
color: ext.accent,
size: 20,
),
),
],
),
).animate().fadeIn(duration: 300.ms).slideY(begin: -0.1, end: 0);
}
// ============================================================
// 分区标题
// ============================================================
@@ -149,60 +210,86 @@ class _CountdownPageState extends ConsumerState<CountdownPage> {
int index = 0,
}) {
final color = _parseColor(event.colorHex);
final isFocused = ref.watch(countdownProvider).focusedEventId == event.id;
final isLiveActivitySupported =
ref.watch(liveActivitySupportedProvider);
return GestureDetector(
onLongPress: () => _showEventActions(event),
child: Container(
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: ext.bgCard,
borderRadius: AppRadius.lgBorder,
border: isPinned
onLongPress: () => _showEventActions(event),
child: Container(
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: ext.bgCard,
borderRadius: AppRadius.lgBorder,
border: isFocused
? Border.all(color: ext.accent.withValues(alpha: 0.5))
: isPinned
? Border.all(color: color.withValues(alpha: 0.3))
: null,
),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: AppRadius.mdBorder,
),
child: Center(
child: Text(
event.emoji,
style: const TextStyle(fontSize: 24),
),
),
),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: AppRadius.mdBorder,
),
child: Center(
child: Text(
event.emoji,
style: const TextStyle(fontSize: 24),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
event.title,
style: AppTypography.body.copyWith(
fontWeight: FontWeight.w600,
color: isPast ? ext.textHint : ext.textPrimary,
),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
event.title,
style: AppTypography.body.copyWith(
fontWeight: FontWeight.w600,
color: isPast ? ext.textHint : ext.textPrimary,
),
),
const SizedBox(height: AppSpacing.xs),
Text(
_formatDate(event.targetDate),
style: AppTypography.footnote.copyWith(
color: ext.textHint,
),
),
],
const SizedBox(height: AppSpacing.xs),
Text(
_formatDate(event.targetDate),
style: AppTypography.footnote.copyWith(
color: ext.textHint,
),
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isLiveActivitySupported && !isPast)
Padding(
padding: const EdgeInsets.only(right: 6),
child: GestureDetector(
onTap: () => ref
.read(countdownProvider.notifier)
.focusEvent(event.id),
child: Icon(
isFocused
? CupertinoIcons.bell_fill
: CupertinoIcons.bell,
size: 16,
color: isFocused
? ext.accent
: ext.textDisabled,
),
),
),
Text(
event.isToday ? '🎉' : '${event.daysRemaining.abs()}',
style: AppTypography.title1.copyWith(
@@ -211,18 +298,20 @@ class _CountdownPageState extends ConsumerState<CountdownPage> {
fontSize: event.isToday ? 24 : 28,
),
),
Text(
event.remainingLabel,
style: AppTypography.caption1.copyWith(
color: isPast ? ext.textDisabled : ext.textSecondary,
),
),
],
),
Text(
event.remainingLabel,
style: AppTypography.caption1.copyWith(
color: isPast ? ext.textDisabled : ext.textSecondary,
),
),
],
),
),
)
],
),
),
)
.animate()
.fadeIn(duration: 350.ms, delay: (index * 50).ms)
.slideX(begin: 0.1, end: 0, duration: 350.ms, delay: (index * 50).ms);
@@ -249,10 +338,22 @@ class _CountdownPageState extends ConsumerState<CountdownPage> {
// ============================================================
void _showEventActions(CountdownEvent event) {
final isFocused = ref.read(countdownProvider).focusedEventId == event.id;
final isLiveActivitySupported =
ref.read(liveActivitySupportedProvider);
showCupertinoModalPopup<void>(
context: context,
builder: (ctx) => CupertinoActionSheet(
actions: [
if (isLiveActivitySupported && !event.isPast)
CupertinoActionSheetAction(
onPressed: () {
Navigator.pop(ctx);
ref.read(countdownProvider.notifier).focusEvent(event.id);
},
child: Text(isFocused ? '关闭灵动岛' : '🔔 显示到灵动岛'),
),
CupertinoActionSheetAction(
onPressed: () {
Navigator.pop(ctx);

View File

@@ -1,24 +1,40 @@
/// ============================================================
/// 闲言APP — 倒计时状态管理
/// 创建时间: 2026-05-02
/// 更新时间: 2026-05-02
/// 作用: 倒计时事件 CRUD + 持久化 + 排序
/// 上次更新: 初始创建
/// 更新时间: 2026-06-02
/// 作用: 倒计时事件 CRUD + 持久化 + 排序 + 灵动岛聚焦模式
/// 上次更新: 集成灵动岛Live Activity支持聚焦倒计时实时显示
/// ============================================================
import 'dart:async';
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/storage/kv_storage.dart';
import '../../../core/utils/logger.dart';
import '../../../core/services/device/live_activity_service.dart';
import '../models/countdown_models.dart';
class CountdownState {
const CountdownState({this.events = const [], this.isLoading = true});
const CountdownState({
this.events = const [],
this.isLoading = true,
this.focusedEventId,
});
final List<CountdownEvent> events;
final bool isLoading;
final String? focusedEventId;
CountdownEvent? get focusedEvent {
if (focusedEventId == null) return null;
try {
return events.firstWhere((e) => e.id == focusedEventId);
} catch (_) {
return null;
}
}
List<CountdownEvent> get pinned =>
events.where((e) => e.isPinned).toList()
@@ -32,23 +48,36 @@ class CountdownState {
events.where((e) => !e.isPinned && e.isPast).toList()
..sort((a, b) => b.daysRemaining.compareTo(a.daysRemaining));
CountdownState copyWith({List<CountdownEvent>? events, bool? isLoading}) {
CountdownState copyWith({
List<CountdownEvent>? events,
bool? isLoading,
String? focusedEventId,
bool clearFocused = false,
}) {
return CountdownState(
events: events ?? this.events,
isLoading: isLoading ?? this.isLoading,
focusedEventId: clearFocused ? null : (focusedEventId ?? this.focusedEventId),
);
}
}
class CountdownNotifier extends Notifier<CountdownState> {
Timer? _updateTimer;
@override
CountdownState build() {
Future.microtask(() => _loadEvents()).catchError((_) {});
ref.onDispose(_onDispose);
return const CountdownState(isLoading: false);
}
static const _key = 'countdown_events';
// ============================================================
// 数据持久化
// ============================================================
Future<void> _loadEvents() async {
try {
final raw = KvStorage.getString(_key);
@@ -99,6 +128,10 @@ class CountdownNotifier extends Notifier<CountdownState> {
];
}
// ============================================================
// 事件 CRUD
// ============================================================
Future<void> addEvent(CountdownEvent event) async {
state = state.copyWith(events: [...state.events, event]);
await _saveEvents();
@@ -109,13 +142,23 @@ class CountdownNotifier extends Notifier<CountdownState> {
events: state.events.map((e) => e.id == event.id ? event : e).toList(),
);
await _saveEvents();
if (state.focusedEventId == event.id) {
_updateLiveActivity();
}
}
Future<void> deleteEvent(String id) async {
final wasFocused = state.focusedEventId == id;
state = state.copyWith(
events: state.events.where((e) => e.id != id).toList(),
clearFocused: wasFocused,
);
await _saveEvents();
if (wasFocused) {
_endLiveActivity();
_updateTimer?.cancel();
_updateTimer = null;
}
}
Future<void> togglePin(String id) async {
@@ -126,6 +169,118 @@ class CountdownNotifier extends Notifier<CountdownState> {
);
await _saveEvents();
}
// ============================================================
// 灵动岛聚焦模式
// ============================================================
Future<void> focusEvent(String id) async {
final event = state.events.where((e) => e.id == id).firstOrNull;
if (event == null) return;
if (event.isPast) return;
if (state.focusedEventId == id) {
await unfocusEvent();
return;
}
if (state.focusedEventId != null) {
_endLiveActivity();
_updateTimer?.cancel();
}
state = state.copyWith(focusedEventId: id);
_startLiveActivity();
_updateTimer?.cancel();
_updateTimer = Timer.periodic(const Duration(minutes: 1), (_) {
_updateLiveActivity();
_checkEventArrival();
});
Log.i('CountdownNotifier: 聚焦倒计时 "${event.title}"');
}
Future<void> unfocusEvent() async {
_endLiveActivity();
_updateTimer?.cancel();
_updateTimer = null;
state = state.copyWith(clearFocused: true);
Log.i('CountdownNotifier: 取消聚焦倒计时');
}
void _startLiveActivity() {
final service = LiveActivityService.instance;
if (!service.isSupported) return;
final event = state.focusedEvent;
if (event == null) return;
final remainingMinutes = _calculateRemainingMinutes(event);
service.startCountdownActivity(
title: event.title,
remainingMinutes: remainingMinutes,
emoji: event.emoji,
);
}
void _updateLiveActivity() {
final service = LiveActivityService.instance;
if (!service.isSupported || !service.hasActiveActivity) return;
final event = state.focusedEvent;
if (event == null) {
_endLiveActivity();
return;
}
final remainingMinutes = _calculateRemainingMinutes(event);
service.updateCountdownActivity(
title: event.title,
remainingMinutes: remainingMinutes,
emoji: event.emoji,
);
}
void _endLiveActivity() {
final service = LiveActivityService.instance;
if (!service.isSupported) return;
service.endCountdownActivity();
}
int _calculateRemainingMinutes(CountdownEvent event) {
final now = DateTime.now();
final target = DateTime(
event.targetDate.year,
event.targetDate.month,
event.targetDate.day,
23,
59,
59,
);
final diff = target.difference(now);
return diff.inMinutes.clamp(0, diff.inMinutes);
}
void _checkEventArrival() {
final event = state.focusedEvent;
if (event == null) return;
if (event.isPast || event.isToday) {
_endLiveActivity();
_updateTimer?.cancel();
_updateTimer = null;
state = state.copyWith(clearFocused: true);
Log.i('CountdownNotifier: 倒计时 "${event.title}" 已到达,结束灵动岛');
}
}
void _onDispose() {
_updateTimer?.cancel();
_endLiveActivity();
}
}
final countdownProvider = NotifierProvider<CountdownNotifier, CountdownState>(

View File

@@ -50,6 +50,12 @@ class _RssReaderPageState extends State<RssReaderPage> {
/// 选中的文章(查看详情)
RssFeedItem? _selectedItem;
/// 阅读模式全文
RssFullTextResult? _fullTextResult;
/// 是否正在加载全文
bool _isLoadingFullText = false;
/// 当前分类筛选
RssCategory? _selectedCategory;
@@ -432,6 +438,7 @@ class _RssReaderPageState extends State<RssReaderPage> {
Widget _buildArticleDetail(AppThemeExtension ext) {
final item = _selectedItem!;
final isReadingMode = _fullTextResult != null && _fullTextResult!.success;
return CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
@@ -470,12 +477,18 @@ class _RssReaderPageState extends State<RssReaderPage> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
style: AppTypography.title2.copyWith(
color: ext.textPrimary,
fontWeight: FontWeight.bold,
),
Row(
children: [
Expanded(
child: Text(
isReadingMode ? (_fullTextResult!.title ?? item.title) : item.title,
style: AppTypography.title2.copyWith(
color: ext.textPrimary,
fontWeight: FontWeight.bold,
),
),
),
],
),
if (item.sourceTitle != null ||
item.author != null ||
@@ -484,8 +497,23 @@ class _RssReaderPageState extends State<RssReaderPage> {
_buildMetaRow(ext, item),
],
const SizedBox(height: AppSpacing.md),
if (item.description != null &&
item.description!.isNotEmpty)
if (_isLoadingFullText) ...[
const Center(child: CupertinoActivityIndicator()),
const SizedBox(height: AppSpacing.md),
] else if (isReadingMode) ...[
Text(
_fullTextResult!.content ?? '',
style: AppTypography.body.copyWith(
color: ext.textPrimary,
height: 1.8,
),
),
if (_fullTextResult!.images.isNotEmpty) ...[
const SizedBox(height: AppSpacing.lg),
_buildImageGallery(ext, _fullTextResult!.images),
],
] else if (item.description != null &&
item.description!.isNotEmpty) ...[
Text(
HtmlUtils.stripTags(item.description!),
style: AppTypography.body.copyWith(
@@ -493,33 +521,66 @@ class _RssReaderPageState extends State<RssReaderPage> {
height: 1.7,
),
),
],
const SizedBox(height: AppSpacing.lg),
if (item.link != null) ...[
const SizedBox(height: AppSpacing.lg),
SizedBox(
width: double.infinity,
child: CupertinoButton(
color: ext.accent,
borderRadius: AppRadius.lgBorder,
onPressed: () => _launchUrl(item.link!),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
CupertinoIcons.globe,
size: 16,
color: ext.textOnAccent,
Row(
children: [
Expanded(
child: CupertinoButton(
color: ext.accent,
borderRadius: AppRadius.lgBorder,
onPressed: () => _launchUrl(item.link!),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
CupertinoIcons.globe,
size: 16,
color: ext.textOnAccent,
),
const SizedBox(width: AppSpacing.xs),
Text(
'在浏览器中打开',
style: AppTypography.subhead.copyWith(
color: ext.textOnAccent,
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(width: AppSpacing.xs),
Text(
'在浏览器中打开',
style: AppTypography.subhead.copyWith(
color: ext.textOnAccent,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
const SizedBox(width: AppSpacing.sm),
if (!isReadingMode)
CupertinoButton(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
color: ext.accent.withValues(alpha: 0.12),
borderRadius: AppRadius.lgBorder,
onPressed: _loadFullText,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
CupertinoIcons.doc_text,
size: 16,
color: ext.accent,
),
const SizedBox(width: AppSpacing.xs),
Text(
'📖 阅读模式',
style: AppTypography.subhead.copyWith(
color: ext.accent,
fontWeight: FontWeight.w600,
),
),
],
),
),
],
),
],
],
@@ -532,6 +593,71 @@ class _RssReaderPageState extends State<RssReaderPage> {
);
}
/// 加载全文内容(阅读模式)
Future<void> _loadFullText() async {
if (_selectedItem?.link == null) return;
setState(() => _isLoadingFullText = true);
try {
final result = await RssService.fetchFullText(_selectedItem!.link!);
if (mounted) {
setState(() {
_fullTextResult = result;
_isLoadingFullText = false;
});
}
} catch (e) {
if (mounted) {
setState(() => _isLoadingFullText = false);
_showToast('📖 全文加载失败');
}
}
}
/// 构建图片画廊
Widget _buildImageGallery(AppThemeExtension ext, List<String> images) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'📎 文中图片',
style: AppTypography.subhead.copyWith(
color: ext.textSecondary,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: AppSpacing.sm),
Wrap(
spacing: AppSpacing.sm,
runSpacing: AppSpacing.sm,
children: images.map((url) {
return GestureDetector(
onTap: () => _launchUrl(url),
child: ClipRRect(
borderRadius: AppRadius.mdBorder,
child: CachedNetworkImage(
imageUrl: url,
width: 100,
height: 100,
fit: BoxFit.cover,
placeholder: (_, __) => Container(
width: 100,
height: 100,
decoration: BoxDecoration(
color: ext.bgSecondary,
borderRadius: AppRadius.mdBorder,
),
child: const CupertinoActivityIndicator(radius: 8),
),
errorWidget: (_, __, ___) => const SizedBox.shrink(),
),
),
);
}).toList(),
),
],
);
}
/// ── 文章元信息行 ──
Widget _buildMetaRow(AppThemeExtension ext, RssFeedItem item) {
return Container(
@@ -896,7 +1022,11 @@ class _RssReaderPageState extends State<RssReaderPage> {
}
void _closeDetail() {
setState(() => _selectedItem = null);
setState(() {
_selectedItem = null;
_fullTextResult = null;
_isLoadingFullText = false;
});
}
void _backToSubscriptions() {

View File

@@ -561,4 +561,175 @@ class RssService {
.replaceAll('&quot;', '"')
.replaceAll('&amp;', '&');
}
// ============================================================
// 全文提取(阅读模式)
// ============================================================
/// 从文章URL提取全文内容阅读模式
///
/// 使用简易 Readability 算法:
/// 1. 获取网页HTML
/// 2. 移除导航/侧边栏/页脚等非正文区域
/// 3. 提取最可能是正文的区域
/// 4. 清理HTML标签返回纯文本
static Future<RssFullTextResult> fetchFullText(String url) async {
try {
final response = await _dio.get<String>(url);
final html = response.data ?? '';
if (html.isEmpty) {
return const RssFullTextResult(error: '页面内容为空');
}
final title = _extractTitle(html);
final content = _extractContent(html);
final images = _extractContentImages(html);
if (content.isEmpty) {
return RssFullTextResult(
error: '无法提取正文内容',
title: title,
);
}
return RssFullTextResult(
success: true,
title: title,
content: content,
images: images,
sourceUrl: url,
);
} catch (e) {
Log.e('RssService', '全文提取失败 [$url]: $e');
return RssFullTextResult(error: '加载失败: $e');
}
}
/// 提取页面标题
static String _extractTitle(String html) {
final ogTitle = RegExp(r'<meta[^>]*property=["\x27]og:title["\x27][^>]*content=["\x27]([^"\x27]*)["\x27]', caseSensitive: false)
.firstMatch(html);
if (ogTitle != null && ogTitle.group(1)!.isNotEmpty) {
return _decodeHtmlEntities(ogTitle.group(1)!);
}
final titleMatch = RegExp(r'<title[^>]*>(.*?)</title>', caseSensitive: false, dotAll: true)
.firstMatch(html);
if (titleMatch != null && titleMatch.group(1)!.isNotEmpty) {
return _decodeHtmlEntities(titleMatch.group(1)!.trim());
}
return '';
}
/// 提取正文内容(简易 Readability
static String _extractContent(String html) {
var cleaned = html;
// 移除脚本和样式
cleaned = cleaned.replaceAll(
RegExp(r'<script[^>]*>.*?</script>', caseSensitive: false, dotAll: true),
'',
);
cleaned = cleaned.replaceAll(
RegExp(r'<style[^>]*>.*?</style>', caseSensitive: false, dotAll: true),
'',
);
cleaned = cleaned.replaceAll(
RegExp(r'<nav[^>]*>.*?</nav>', caseSensitive: false, dotAll: true),
'',
);
cleaned = cleaned.replaceAll(
RegExp(r'<footer[^>]*>.*?</footer>', caseSensitive: false, dotAll: true),
'',
);
cleaned = cleaned.replaceAll(
RegExp(r'<header[^>]*>.*?</header>', caseSensitive: false, dotAll: true),
'',
);
cleaned = cleaned.replaceAll(
RegExp(r'<aside[^>]*>.*?</aside>', caseSensitive: false, dotAll: true),
'',
);
cleaned = cleaned.replaceAll(
RegExp(r'<noscript[^>]*>.*?</noscript>', caseSensitive: false, dotAll: true),
'',
);
// 查找 article 标签
final articleMatch = RegExp(r'<article[^>]*>(.*?)</article>', caseSensitive: false, dotAll: true)
.firstMatch(cleaned);
if (articleMatch != null) {
cleaned = articleMatch.group(1)!;
} else {
// 查找 class 含 article/content/post/entry 的 div
final contentDiv = RegExp(
r'<div[^>]*class=["\x27][^\x27]*(?:article|content|post-body|entry-content|post-content|story-body|article-body|rich-text|markdown-body)[^\x27]*["\x27][^>]*>(.*?)</div>',
caseSensitive: false,
dotAll: true,
).firstMatch(cleaned);
if (contentDiv != null) {
cleaned = contentDiv.group(1)!;
}
}
// 清理HTML标签保留段落结构
var text = cleaned;
text = text.replaceAll(RegExp(r'<br\s*/?>', caseSensitive: false), '\n');
text = text.replaceAll(RegExp(r'<p[^>]*>', caseSensitive: false), '\n');
text = text.replaceAll(RegExp(r'</p>', caseSensitive: false), '\n');
text = text.replaceAll(RegExp(r'<h[1-6][^>]*>', caseSensitive: false), '\n\n');
text = text.replaceAll(RegExp(r'</h[1-6]>', caseSensitive: false), '\n');
text = text.replaceAll(RegExp(r'<li[^>]*>', caseSensitive: false), '');
text = text.replaceAll(RegExp(r'<blockquote[^>]*>', caseSensitive: false), '\n> ');
text = text.replaceAll(RegExp(r'<[^>]*>'), '');
text = text.replaceAll(RegExp(r'&nbsp;'), ' ');
text = text.replaceAll(RegExp(r'&amp;'), '&');
text = text.replaceAll(RegExp(r'&lt;'), '<');
text = text.replaceAll(RegExp(r'&gt;'), '>');
text = text.replaceAll(RegExp(r'&quot;'), '"');
text = text.replaceAll(RegExp(r'&#\d+;'), '');
text = text.replaceAll(RegExp(r'\n{3,}'), '\n\n');
return text.trim();
}
/// 提取正文中的图片URL
static List<String> _extractContentImages(String html) {
final imgRegex = RegExp(r'<img[^>]+src\s*=\s*["\x27]([^"\x27]+)["\x27]', dotAll: true);
return imgRegex
.allMatches(html)
.map((m) => m.group(1) ?? '')
.where((url) => url.isNotEmpty && !url.endsWith('.svg') && !url.contains('avatar') && !url.contains('icon') && !url.contains('logo'))
.take(10)
.toList();
}
/// HTML实体解码
static String _decodeHtmlEntities(String text) {
return text
.replaceAll('&amp;', '&')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&#39;', "'")
.replaceAll('&nbsp;', ' ');
}
}
/// 全文提取结果
class RssFullTextResult {
const RssFullTextResult({
this.success = false,
this.title,
this.content,
this.images = const [],
this.sourceUrl,
this.error,
});
final bool success;
final String? title;
final String? content;
final List<String> images;
final String? sourceUrl;
final String? error;
}

View File

@@ -370,7 +370,7 @@ extension _TransferChatFileSendExt on _TransferChatPageState {
Future<void> _pickFile() async {
try {
final result = await FilePicker.pickFiles(allowMultiple: true);
final result = await FilePicker.pickFiles();
if (result != null && result.files.isNotEmpty) {
final paths = result.files
.where((f) => f.path != null)

View File

@@ -304,7 +304,6 @@ class FontDownloadService {
final result = await FilePicker.pickFiles(
type: FileType.custom,
allowedExtensions: ['ttf', 'otf'],
allowMultiple: true,
withData: true,
);

View File

@@ -0,0 +1,39 @@
/// ============================================================
/// 闲言APP — 延迟渲染包装组件
/// 创建时间: 2026-06-02
/// 更新时间: 2026-06-02
/// 作用: 将子组件(如syncfusion chart)延迟到postFrameCallback渲染
/// 避免chart在build阶段触发markNeedsLayout导致卡死
/// 上次更新: 初始创建
/// ============================================================
import 'package:flutter/widgets.dart';
class DeferredBuilder extends StatefulWidget {
const DeferredBuilder({super.key, required this.builder});
final WidgetBuilder builder;
@override
State<DeferredBuilder> createState() => _DeferredBuilderState();
}
class _DeferredBuilderState extends State<DeferredBuilder> {
bool _ready = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() => _ready = true);
});
}
@override
Widget build(BuildContext context) {
if (!_ready) {
return const SizedBox.expand();
}
return widget.builder(context);
}
}