同步
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
120
lib/core/services/device/macos_platform_service.dart
Normal file
120
lib/core/services/device/macos_platform_service.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
12
lib/core/services/ui/macos_title_bar_service.dart
Normal file
12
lib/core/services/ui/macos_title_bar_service.dart
Normal 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';
|
||||
187
lib/features/auth/services/qrcode_ws_service.dart
Normal file
187
lib/features/auth/services/qrcode_ws_service.dart
Normal 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';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -561,4 +561,175 @@ class RssService {
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll('&', '&');
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 全文提取(阅读模式)
|
||||
// ============================================================
|
||||
|
||||
/// 从文章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' '), ' ');
|
||||
text = text.replaceAll(RegExp(r'&'), '&');
|
||||
text = text.replaceAll(RegExp(r'<'), '<');
|
||||
text = text.replaceAll(RegExp(r'>'), '>');
|
||||
text = text.replaceAll(RegExp(r'"'), '"');
|
||||
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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll(' ', ' ');
|
||||
}
|
||||
}
|
||||
|
||||
/// 全文提取结果
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -304,7 +304,6 @@ class FontDownloadService {
|
||||
final result = await FilePicker.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['ttf', 'otf'],
|
||||
allowMultiple: true,
|
||||
withData: true,
|
||||
);
|
||||
|
||||
|
||||
39
lib/shared/widgets/containers/deferred_builder.dart
Normal file
39
lib/shared/widgets/containers/deferred_builder.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user