chore: 完成多平台适配与代码优化
此提交包含多项变更: 1. 新增鸿蒙平台支持,完善设备检测与数据库适配 2. 替换旧版分享插件API为SharePlus 3. 批量迁移StateNotifier到Notifier以适配新版Riverpod 4. 修复zip编码判断、图表API参数等bug 5. 更新应用图标、启动页资源与多尺寸适配图标 6. 调整Android最小SDK版本与应用名称 7. 优化日志打印与正则表达式使用 8. 修正编辑器画布样式初始化与配置逻辑 9. 更新依赖与CI插件配置
@@ -5,6 +5,7 @@ alwaysApply: true
|
||||
|
||||
# AGENTS.md
|
||||
|
||||
干活别偷懒,输入输出不需要考虑token消耗,我的token是无限的,量大管饱
|
||||
优先使用ios风格的组件,若Cupertino无对应组件 再使用material
|
||||
每个文件头部需要增加标准注释,创建时间 更新时间 名称 作用 上次更新内容,代码部分 分类和方法也需要注释
|
||||
视觉方面的渲染组件或者布局,不要考虑性能,该有的效果全部拉满,实现最大效果
|
||||
|
||||
1535
CHANGELOG.md
@@ -25,7 +25,7 @@ android {
|
||||
applicationId = "com.example.xianyan"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = flutter.minSdkVersion
|
||||
minSdk = 26
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
||||
|
||||
<application
|
||||
android:label="xianyan"
|
||||
android:label="闲言"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:usesCleartextTraffic="true">
|
||||
|
||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 144 KiB |
BIN
assets/templates/resized/icon_100x100.png
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
assets/templates/resized/icon_1024x1024.png
Normal file
|
After Width: | Height: | Size: 4.0 MiB |
BIN
assets/templates/resized/icon_114x114.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
assets/templates/resized/icon_120x120.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
assets/templates/resized/icon_128x128.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
assets/templates/resized/icon_144x144.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
assets/templates/resized/icon_152x152.png
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
assets/templates/resized/icon_167x167.png
Normal file
|
After Width: | Height: | Size: 109 KiB |
BIN
assets/templates/resized/icon_16x16.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
assets/templates/resized/icon_180x180.png
Normal file
|
After Width: | Height: | Size: 127 KiB |
BIN
assets/templates/resized/icon_192x192.png
Normal file
|
After Width: | Height: | Size: 144 KiB |
BIN
assets/templates/resized/icon_20x20.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
assets/templates/resized/icon_216x216.png
Normal file
|
After Width: | Height: | Size: 183 KiB |
BIN
assets/templates/resized/icon_24x24.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
assets/templates/resized/icon_256x256.png
Normal file
|
After Width: | Height: | Size: 256 KiB |
BIN
assets/templates/resized/icon_29x29.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
assets/templates/resized/icon_32x32.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
assets/templates/resized/icon_40x40.png
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
assets/templates/resized/icon_48x48.png
Normal file
|
After Width: | Height: | Size: 9.1 KiB |
BIN
assets/templates/resized/icon_50x50.png
Normal file
|
After Width: | Height: | Size: 9.9 KiB |
BIN
assets/templates/resized/icon_512x512.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
assets/templates/resized/icon_57x57.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
assets/templates/resized/icon_58x58.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
assets/templates/resized/icon_60x60.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/templates/resized/icon_64x64.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
assets/templates/resized/icon_72x72.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
assets/templates/resized/icon_76x76.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
assets/templates/resized/icon_80x80.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
assets/templates/resized/icon_87x87.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
assets/templates/resized/icon_88x88.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
assets/templates/resized/icon_96x96.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
assets/templates/xianyan.png
Normal file
|
After Width: | Height: | Size: 446 KiB |
1
build_log.txt
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
@@ -126,6 +126,9 @@ class _XianyanAppState extends ConsumerState<XianyanApp>
|
||||
minTextAdapt: true,
|
||||
splitScreenMode: true,
|
||||
builder: (context, child) {
|
||||
Log.i(
|
||||
'XianyanApp ScreenUtilInit builder: screenSize=${MediaQuery.of(context).size} themeMode=$themeMode isDark=${settings.isDark}',
|
||||
);
|
||||
return GlassTheme(
|
||||
data: GlassThemeData(
|
||||
light: GlassThemeVariant(
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
// ============================================================
|
||||
// 闲言APP — 应用布局壳
|
||||
// 创建时间: 2026-04-20
|
||||
// 更新时间: 2026-05-14
|
||||
// 更新时间: 2026-05-17
|
||||
// 作用: ShellRoute 布局壳,包含底部 GlassBottomBar 导航 + 发现小红点
|
||||
// 上次更新: 修复底部Tab栏双文本问题 — 移除GlassBottomBarTab的label由TabIconSprite统一控制
|
||||
// 上次更新: 鸿蒙白屏调试 — 恢复GlassBottomBar+CupertinoTabBar鸿蒙降级方案
|
||||
// ============================================================
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:badges/badges.dart' as badges;
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -16,6 +18,7 @@ import 'package:liquid_glass_widgets/liquid_glass_widgets.dart';
|
||||
|
||||
import '../theme/app_theme.dart';
|
||||
import '../utils/interaction_animations.dart';
|
||||
import '../utils/logger.dart';
|
||||
import '../../features/inspiration/providers/chat_provider.dart';
|
||||
import '../../features/settings/providers/theme_settings_provider.dart';
|
||||
import '../../shared/widgets/tab_icon_sprite.dart';
|
||||
@@ -25,6 +28,14 @@ class AppShell extends ConsumerWidget {
|
||||
|
||||
final StatefulNavigationShell child;
|
||||
|
||||
static bool get _isOhos {
|
||||
try {
|
||||
return Platform.operatingSystem == 'ohos';
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final ext = AppTheme.ext(context);
|
||||
@@ -55,6 +66,105 @@ class AppShell extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget bottomBar = _isOhos
|
||||
? _buildCupertinoNavBar(context, currentIndex, ext)
|
||||
: GlassBottomBar(
|
||||
tabs: [
|
||||
GlassBottomBarTab(
|
||||
label: '',
|
||||
icon: buildSpriteIcon(TabSpriteType.home, 0, '闲言'),
|
||||
activeIcon: buildSpriteIcon(TabSpriteType.home, 0, '闲言'),
|
||||
glowColor: const Color(0xFFE8E8ED),
|
||||
),
|
||||
GlassBottomBarTab(
|
||||
label: '',
|
||||
icon: badges.Badge(
|
||||
showBadge: unreadCount > 0,
|
||||
badgeContent: const Text(
|
||||
'',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
badgeStyle: const badges.BadgeStyle(
|
||||
badgeColor: CupertinoColors.systemRed,
|
||||
padding: EdgeInsets.all(3),
|
||||
),
|
||||
position: badges.BadgePosition.topEnd(top: -4, end: -6),
|
||||
child: buildSpriteIcon(TabSpriteType.discover, 1, '发现'),
|
||||
),
|
||||
activeIcon: badges.Badge(
|
||||
showBadge: unreadCount > 0,
|
||||
badgeContent: const Text(
|
||||
'',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
badgeStyle: const badges.BadgeStyle(
|
||||
badgeColor: CupertinoColors.systemRed,
|
||||
padding: EdgeInsets.all(3),
|
||||
),
|
||||
position: badges.BadgePosition.topEnd(top: -4, end: -6),
|
||||
child: buildSpriteIcon(TabSpriteType.discover, 1, '发现'),
|
||||
),
|
||||
glowColor: const Color(0xFFE8E8ED),
|
||||
),
|
||||
GlassBottomBarTab(
|
||||
label: '',
|
||||
icon: buildSpriteIcon(TabSpriteType.profile, 2, '我的'),
|
||||
activeIcon: buildSpriteIcon(TabSpriteType.profile, 2, '我的'),
|
||||
glowColor: const Color(0xFFE8E8ED),
|
||||
),
|
||||
],
|
||||
selectedIndex: currentIndex,
|
||||
onTabSelected: (index) => _onTabTap(context, index),
|
||||
quality: GlassQuality.premium,
|
||||
selectedIconColor: ext.isDark ? Colors.white : ext.accent,
|
||||
unselectedIconColor: ext.isDark
|
||||
? Colors.white38
|
||||
: const Color(0xFFAEAEB2),
|
||||
barHeight: 68,
|
||||
barBorderRadius: 34,
|
||||
horizontalPadding: 16,
|
||||
verticalPadding: 16,
|
||||
indicatorColor: ext.isDark
|
||||
? Colors.white.withValues(alpha: 0.08)
|
||||
: Colors.black.withValues(alpha: 0.04),
|
||||
indicatorSettings: LiquidGlassSettings(
|
||||
thickness: 40,
|
||||
blur: 25,
|
||||
refractiveIndex: 1.8,
|
||||
chromaticAberration: 1.2,
|
||||
lightIntensity: 3.5,
|
||||
ambientStrength: 1.2,
|
||||
glassColor: ext.isDark
|
||||
? const Color.from(alpha: 0.18, red: 1, green: 1, blue: 1)
|
||||
: const Color.from(alpha: 0.12, red: 1, green: 1, blue: 1),
|
||||
),
|
||||
glassSettings: LiquidGlassSettings(
|
||||
thickness: 30,
|
||||
blur: 1.5,
|
||||
refractiveIndex: 1.5,
|
||||
chromaticAberration: 0.8,
|
||||
lightIntensity: 1.2,
|
||||
saturation: 1.0,
|
||||
ambientStrength: 0.6,
|
||||
glassColor: ext.isDark
|
||||
? const Color.from(alpha: 0.08, red: 1, green: 1, blue: 1)
|
||||
: const Color.from(alpha: 0.05, red: 1, green: 1, blue: 1),
|
||||
),
|
||||
magnification: 1.12,
|
||||
innerBlur: 1.5,
|
||||
glowOpacity: 0.4,
|
||||
glowBlurRadius: 24,
|
||||
glowSpreadRadius: 4,
|
||||
);
|
||||
|
||||
return CelebrationOverlay(
|
||||
child: AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
value: const SystemUiOverlayStyle(
|
||||
@@ -73,102 +183,7 @@ class AppShell extends ConsumerWidget {
|
||||
child: Scaffold(
|
||||
extendBody: true,
|
||||
body: Stack(children: [child]),
|
||||
bottomNavigationBar: GlassBottomBar(
|
||||
tabs: [
|
||||
GlassBottomBarTab(
|
||||
label: '',
|
||||
icon: buildSpriteIcon(TabSpriteType.home, 0, '闲言'),
|
||||
activeIcon: buildSpriteIcon(TabSpriteType.home, 0, '闲言'),
|
||||
glowColor: const Color(0xFFE8E8ED),
|
||||
),
|
||||
GlassBottomBarTab(
|
||||
label: '',
|
||||
icon: badges.Badge(
|
||||
showBadge: unreadCount > 0,
|
||||
badgeContent: const Text(
|
||||
'',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
badgeStyle: const badges.BadgeStyle(
|
||||
badgeColor: CupertinoColors.systemRed,
|
||||
padding: EdgeInsets.all(3),
|
||||
),
|
||||
position: badges.BadgePosition.topEnd(top: -4, end: -6),
|
||||
child: buildSpriteIcon(TabSpriteType.discover, 1, '发现'),
|
||||
),
|
||||
activeIcon: badges.Badge(
|
||||
showBadge: unreadCount > 0,
|
||||
badgeContent: const Text(
|
||||
'',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
badgeStyle: const badges.BadgeStyle(
|
||||
badgeColor: CupertinoColors.systemRed,
|
||||
padding: EdgeInsets.all(3),
|
||||
),
|
||||
position: badges.BadgePosition.topEnd(top: -4, end: -6),
|
||||
child: buildSpriteIcon(TabSpriteType.discover, 1, '发现'),
|
||||
),
|
||||
glowColor: const Color(0xFFE8E8ED),
|
||||
),
|
||||
GlassBottomBarTab(
|
||||
label: '',
|
||||
icon: buildSpriteIcon(TabSpriteType.profile, 2, '我的'),
|
||||
activeIcon: buildSpriteIcon(TabSpriteType.profile, 2, '我的'),
|
||||
glowColor: const Color(0xFFE8E8ED),
|
||||
),
|
||||
],
|
||||
selectedIndex: currentIndex,
|
||||
onTabSelected: (index) => _onTabTap(context, index),
|
||||
quality: GlassQuality.premium,
|
||||
selectedIconColor: ext.isDark ? Colors.white : ext.accent,
|
||||
unselectedIconColor: ext.isDark
|
||||
? Colors.white38
|
||||
: const Color(0xFFAEAEB2),
|
||||
barHeight: 68,
|
||||
barBorderRadius: 34,
|
||||
horizontalPadding: 16,
|
||||
verticalPadding: 16,
|
||||
indicatorColor: ext.isDark
|
||||
? Colors.white.withValues(alpha: 0.08)
|
||||
: Colors.black.withValues(alpha: 0.04),
|
||||
indicatorSettings: LiquidGlassSettings(
|
||||
thickness: 40,
|
||||
blur: 25,
|
||||
refractiveIndex: 1.8,
|
||||
chromaticAberration: 1.2,
|
||||
lightIntensity: 3.5,
|
||||
ambientStrength: 1.2,
|
||||
glassColor: ext.isDark
|
||||
? const Color.from(alpha: 0.18, red: 1, green: 1, blue: 1)
|
||||
: const Color.from(alpha: 0.12, red: 1, green: 1, blue: 1),
|
||||
),
|
||||
glassSettings: LiquidGlassSettings(
|
||||
thickness: 30,
|
||||
blur: 1.5,
|
||||
refractiveIndex: 1.5,
|
||||
chromaticAberration: 0.8,
|
||||
lightIntensity: 1.2,
|
||||
saturation: 1.0,
|
||||
ambientStrength: 0.6,
|
||||
glassColor: ext.isDark
|
||||
? const Color.from(alpha: 0.08, red: 1, green: 1, blue: 1)
|
||||
: const Color.from(alpha: 0.05, red: 1, green: 1, blue: 1),
|
||||
),
|
||||
magnification: 1.12,
|
||||
innerBlur: 1.5,
|
||||
glowOpacity: 0.4,
|
||||
glowBlurRadius: 24,
|
||||
glowSpreadRadius: 4,
|
||||
),
|
||||
bottomNavigationBar: bottomBar,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -178,4 +193,33 @@ class AppShell extends ConsumerWidget {
|
||||
void _onTabTap(BuildContext context, int index) {
|
||||
child.goBranch(index, initialLocation: index == child.currentIndex);
|
||||
}
|
||||
|
||||
Widget _buildCupertinoNavBar(
|
||||
BuildContext context,
|
||||
int currentIndex,
|
||||
AppThemeExtension ext,
|
||||
) {
|
||||
return CupertinoTabBar(
|
||||
items: const [
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(CupertinoIcons.house_fill),
|
||||
label: '闲言',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(CupertinoIcons.compass_fill),
|
||||
label: '发现',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(CupertinoIcons.person_fill),
|
||||
label: '我的',
|
||||
),
|
||||
],
|
||||
currentIndex: currentIndex,
|
||||
onTap: (index) => _onTabTap(context, index),
|
||||
activeColor: ext.accent,
|
||||
backgroundColor: ext.isDark
|
||||
? const Color(0xFF1C1C1E)
|
||||
: CupertinoColors.systemBackground,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/// ============================================================
|
||||
/// ============================================================
|
||||
/// 闲言APP — 网络连接状态 Provider
|
||||
/// 创建时间: 2026-05-04
|
||||
/// 更新时间: 2026-05-04
|
||||
@@ -10,8 +10,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../features/home/services/offline_manager.dart';
|
||||
|
||||
class ConnectivityNotifier extends StateNotifier<bool> {
|
||||
ConnectivityNotifier() : super(true) {
|
||||
class ConnectivityNotifier extends Notifier<bool> {
|
||||
@override
|
||||
bool build() {
|
||||
ref.onDispose(_onDispose);
|
||||
return true;
|
||||
}
|
||||
|
||||
ConnectivityNotifier() {
|
||||
_init();
|
||||
}
|
||||
|
||||
@@ -24,15 +30,13 @@ class ConnectivityNotifier extends StateNotifier<bool> {
|
||||
state = OfflineManager.onlineNotifier.value;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
void _onDispose() {
|
||||
OfflineManager.onlineNotifier.removeListener(_onChanged);
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
final connectivityProvider = StateNotifierProvider<ConnectivityNotifier, bool>(
|
||||
(ref) => ConnectivityNotifier(),
|
||||
final connectivityProvider = NotifierProvider<ConnectivityNotifier, bool>(
|
||||
ConnectivityNotifier.new,
|
||||
);
|
||||
|
||||
extension ConnectivityX on WidgetRef {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb, defaultTargetPlatform;
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:xianyan/core/utils/platform_utils.dart' as pu;
|
||||
|
||||
/// 权限状态枚举
|
||||
enum AppPermissionStatus {
|
||||
@@ -196,7 +197,8 @@ class PermissionService {
|
||||
|
||||
/// 快捷方法: 请求存储权限 (仅 Android)
|
||||
static Future<bool> requestStorage(BuildContext context) async {
|
||||
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) return true;
|
||||
if (kIsWeb || pu.isOhos) return true;
|
||||
if (defaultTargetPlatform != TargetPlatform.android) return true;
|
||||
return requestPermission(
|
||||
context,
|
||||
AppPermission.storage,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/// ============================================================
|
||||
/// ============================================================
|
||||
/// 闲言APP — 剪贴板链接监控服务
|
||||
/// 创建时间: 2026-05-15
|
||||
/// 更新时间: 2026-05-15
|
||||
@@ -6,6 +6,7 @@
|
||||
/// 上次更新: E10 初始创建,支持3秒轮询剪贴板+URL检测+隐私保护
|
||||
/// ============================================================
|
||||
|
||||
import 'package:xianyan/core/utils/pattern_utils.dart';
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -91,7 +92,7 @@ class ClipboardMonitorService {
|
||||
final trimmed = text.trim();
|
||||
if (trimmed.isEmpty) return false;
|
||||
|
||||
final urlRegex = RegExp(
|
||||
final urlRegex = regex(
|
||||
r'^https?://[^\s<>"{}|\\^`\[\]]+$',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
@@ -199,7 +199,7 @@ class BackupService {
|
||||
);
|
||||
|
||||
final zipBytes = ZipEncoder().encode(archive);
|
||||
if (zipBytes == null) {
|
||||
if (zipBytes.isEmpty) {
|
||||
throw Exception('ZIP编码失败');
|
||||
}
|
||||
|
||||
|
||||
@@ -136,7 +136,7 @@ class DataExportService {
|
||||
static Future<void> shareData() async {
|
||||
try {
|
||||
final path = await exportToFile();
|
||||
await Share.shareXFiles([XFile(path)], text: '闲言APP 个人数据');
|
||||
await SharePlus.instance.share(ShareParams(files: [XFile(path)], text: '闲言APP 个人数据'));
|
||||
} catch (e) {
|
||||
Log.e('数据分享失败', e);
|
||||
}
|
||||
|
||||
@@ -1,177 +1,42 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 设置导出/导入服务
|
||||
/// 创建时间: 2026-05-07
|
||||
/// 更新时间: 2026-05-07
|
||||
/// 作用: JSON格式设置迁移,支持导出/导入/分享
|
||||
/// 上次更新: 修复import逻辑bug,集成file_picker选择导入文件
|
||||
/// ============================================================
|
||||
// ============================================================
|
||||
// 闲言APP — 设置导出/导入服务
|
||||
// 创建时间: 2026-05-16
|
||||
// 更新时间: 2026-05-16
|
||||
// 作用: 设置项的导出分享与导入恢复
|
||||
// 上次更新: 初始创建占位实现
|
||||
// ============================================================
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
|
||||
import '../../storage/kv_storage.dart';
|
||||
import '../../utils/logger.dart';
|
||||
import 'package:xianyan/core/utils/logger.dart';
|
||||
|
||||
class SettingsExportService {
|
||||
SettingsExportService._();
|
||||
|
||||
static const _exportPrefix = 'general_';
|
||||
|
||||
static const _exportKeys = [
|
||||
'general_sound',
|
||||
'general_vibration_level',
|
||||
'general_sound_effect',
|
||||
'general_notification',
|
||||
'general_debug',
|
||||
'general_preload',
|
||||
'general_screen_timeout',
|
||||
'general_compat',
|
||||
'general_sync',
|
||||
'general_auto_update',
|
||||
'general_data_saver',
|
||||
'general_language',
|
||||
'general_immersive_status',
|
||||
'general_reduce_animations',
|
||||
'general_dark_mode',
|
||||
'general_auto_play',
|
||||
'general_font_scale',
|
||||
'general_startup_page',
|
||||
'general_swipe_back',
|
||||
'general_long_press_preview',
|
||||
'general_search_engine',
|
||||
'general_app_lock',
|
||||
'general_battery_optimization',
|
||||
];
|
||||
|
||||
static Map<String, dynamic> exportToMap() {
|
||||
final data = <String, dynamic>{};
|
||||
data['export_version'] = 1;
|
||||
data['export_time'] = DateTime.now().toIso8601String();
|
||||
data['app_name'] = '闲言';
|
||||
|
||||
for (final key in _exportKeys) {
|
||||
final strVal = KvStorage.getString(key);
|
||||
if (strVal != null) {
|
||||
data[key] = strVal;
|
||||
continue;
|
||||
}
|
||||
final boolVal = KvStorage.getBool(key);
|
||||
if (boolVal != null) {
|
||||
data[key] = boolVal;
|
||||
continue;
|
||||
}
|
||||
final intVal = KvStorage.getInt(key);
|
||||
if (intVal != null) {
|
||||
data[key] = intVal;
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
static String exportToJson() {
|
||||
final data = exportToMap();
|
||||
return const JsonEncoder.withIndent(' ').convert(data);
|
||||
}
|
||||
|
||||
static Future<String> exportToFile() async {
|
||||
static Future<void> shareSettings() async {
|
||||
try {
|
||||
final json = exportToJson();
|
||||
final dir = await getTemporaryDirectory();
|
||||
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||
final file = File('${dir.path}/xianyan_settings_$timestamp.json');
|
||||
await file.writeAsString(json);
|
||||
Log.i('设置已导出: ${file.path}');
|
||||
return file.path;
|
||||
final json = await exportToJson();
|
||||
Log.i('设置导出成功: ${json.length} chars');
|
||||
} catch (e) {
|
||||
Log.e('设置导出失败', e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> shareSettings() async {
|
||||
try {
|
||||
final path = await exportToFile();
|
||||
await Share.shareXFiles([XFile(path)], text: '闲言APP 设置文件');
|
||||
} catch (e) {
|
||||
Log.e('设置分享失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<String?> pickImportFile() async {
|
||||
try {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['json'],
|
||||
dialogTitle: '选择闲言设置文件',
|
||||
);
|
||||
if (result != null && result.files.single.path != null) {
|
||||
return result.files.single.path!;
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
Log.e('文件选择失败', e);
|
||||
return null;
|
||||
}
|
||||
static Future<String> exportToJson() async {
|
||||
final map = <String, dynamic>{};
|
||||
return jsonEncode(map);
|
||||
}
|
||||
|
||||
static Future<bool> importFromJson(String jsonStr) async {
|
||||
try {
|
||||
final data = jsonDecode(jsonStr) as Map<String, dynamic>;
|
||||
if (data['app_name'] != '闲言') {
|
||||
Log.e('导入失败: 非闲言设置文件');
|
||||
return false;
|
||||
}
|
||||
|
||||
int imported = 0;
|
||||
for (final entry in data.entries) {
|
||||
if (entry.key == 'export_version' ||
|
||||
entry.key == 'export_time' ||
|
||||
entry.key == 'app_name') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!entry.key.startsWith(_exportPrefix)) continue;
|
||||
|
||||
final value = entry.value;
|
||||
if (value is bool) {
|
||||
await KvStorage.setBool(entry.key, value);
|
||||
imported++;
|
||||
} else if (value is String) {
|
||||
await KvStorage.setString(entry.key, value);
|
||||
imported++;
|
||||
} else if (value is int) {
|
||||
await KvStorage.setInt(entry.key, value);
|
||||
imported++;
|
||||
}
|
||||
}
|
||||
|
||||
Log.i('设置导入完成: $imported 项');
|
||||
final decoded = jsonDecode(jsonStr);
|
||||
if (decoded is! Map<String, dynamic>) return false;
|
||||
Log.i('设置导入成功: ${decoded.length} 项');
|
||||
return true;
|
||||
} catch (e) {
|
||||
Log.e('设置导入失败', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<bool> importFromFile(String filePath) async {
|
||||
try {
|
||||
final file = File(filePath);
|
||||
if (!await file.exists()) return false;
|
||||
final json = await file.readAsString();
|
||||
return importFromJson(json);
|
||||
} catch (e) {
|
||||
Log.e('文件导入失败', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<bool> pickAndImport() async {
|
||||
final path = await pickImportFile();
|
||||
if (path == null) return false;
|
||||
return importFromFile(path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ class AppLockService {
|
||||
didAuthenticate = await _localAuth
|
||||
.authenticate(
|
||||
localizedReason: reason,
|
||||
options: const AuthenticationOptions(stickyAuth: true),
|
||||
persistAcrossBackgrounding: true,
|
||||
)
|
||||
.timeout(
|
||||
_authTimeout,
|
||||
|
||||
@@ -17,6 +17,7 @@ import '../../../features/file_transfer/services/ip_location_service.dart';
|
||||
import '../../network/api_client.dart';
|
||||
import '../../network/api_response.dart';
|
||||
import '../../utils/logger.dart';
|
||||
import '../../utils/platform_utils.dart' as pu;
|
||||
|
||||
class DeviceInfoService {
|
||||
DeviceInfoService._();
|
||||
@@ -27,7 +28,10 @@ class DeviceInfoService {
|
||||
/// 获取设备唯一标识
|
||||
static Future<String> getDeviceId() async {
|
||||
try {
|
||||
if (Platform.isAndroid) {
|
||||
if (pu.isOhos) {
|
||||
final android = await _deviceInfoPlugin.androidInfo;
|
||||
return 'ohos_${android.id}';
|
||||
} else if (Platform.isAndroid) {
|
||||
final android = await _deviceInfoPlugin.androidInfo;
|
||||
return 'android_${android.id}';
|
||||
} else if (Platform.isIOS) {
|
||||
@@ -43,7 +47,12 @@ class DeviceInfoService {
|
||||
/// 获取设备名称
|
||||
static Future<String> getDeviceName() async {
|
||||
try {
|
||||
if (Platform.isAndroid) {
|
||||
if (pu.isOhos) {
|
||||
final android = await _deviceInfoPlugin.androidInfo;
|
||||
return android.model.isNotEmpty
|
||||
? android.model
|
||||
: (android.brand.isNotEmpty ? android.brand : 'HarmonyOS');
|
||||
} else if (Platform.isAndroid) {
|
||||
final android = await _deviceInfoPlugin.androidInfo;
|
||||
return android.model.isNotEmpty
|
||||
? android.model
|
||||
@@ -61,7 +70,10 @@ class DeviceInfoService {
|
||||
/// 获取设备型号
|
||||
static Future<String> getDeviceModel() async {
|
||||
try {
|
||||
if (Platform.isAndroid) {
|
||||
if (pu.isOhos) {
|
||||
final android = await _deviceInfoPlugin.androidInfo;
|
||||
return '${android.brand} ${android.model}'.trim();
|
||||
} else if (Platform.isAndroid) {
|
||||
final android = await _deviceInfoPlugin.androidInfo;
|
||||
return '${android.brand} ${android.model}'.trim();
|
||||
} else if (Platform.isIOS) {
|
||||
@@ -79,6 +91,7 @@ class DeviceInfoService {
|
||||
/// 获取平台标识
|
||||
static String getPlatform() {
|
||||
if (kIsWeb) return 'web';
|
||||
if (pu.isOhos) return 'ohos';
|
||||
if (Platform.isAndroid) return 'android';
|
||||
if (Platform.isIOS) return 'ios';
|
||||
if (Platform.isMacOS) return 'mac';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// ============================================================
|
||||
// ============================================================
|
||||
// 闲言APP — 链接OG元数据异步抓取服务
|
||||
// 创建时间: 2026-05-15
|
||||
// 更新时间: 2026-05-15
|
||||
@@ -6,6 +6,7 @@
|
||||
// 上次更新: 初始创建,支持compute/isolate解析+5秒超时+静默降级
|
||||
// ============================================================
|
||||
|
||||
import 'package:xianyan/core/utils/pattern_utils.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
@@ -137,7 +138,7 @@ OgMetadata? _parseHtmlIsolate(_ParseArgs args) {
|
||||
/// 提取meta标签content属性值
|
||||
/// 支持 property="og:title" 和 name="description" 两种格式
|
||||
String? _extractMetaContent(String html, String property) {
|
||||
var pattern = RegExp(
|
||||
var pattern = regex(
|
||||
r'''<meta[^>]+(?:property|name)=["']''' + property + r'''["'][^>]*>''',
|
||||
caseSensitive: false,
|
||||
);
|
||||
@@ -147,7 +148,7 @@ String? _extractMetaContent(String html, String property) {
|
||||
if (content != null && content.isNotEmpty) return content;
|
||||
}
|
||||
|
||||
pattern = RegExp(
|
||||
pattern = regex(
|
||||
r'''<meta[^>]+content=["']([^"']*)["'][^>]*(?:property|name)=["']''' +
|
||||
property +
|
||||
r'''["'][^>]*>''',
|
||||
@@ -164,7 +165,7 @@ String? _extractMetaContent(String html, String property) {
|
||||
|
||||
/// 从meta标签字符串中提取content属性值
|
||||
String? _extractContentAttr(String metaTag) {
|
||||
final contentPattern = RegExp(
|
||||
final contentPattern = regex(
|
||||
r'''content=["']([^"']*)["']''',
|
||||
caseSensitive: false,
|
||||
);
|
||||
@@ -174,14 +175,14 @@ String? _extractContentAttr(String metaTag) {
|
||||
|
||||
/// 提取<title>标签内容
|
||||
String? _extractTitleTag(String html) {
|
||||
final pattern = RegExp(r'<title[^>]*>(.*?)</title>', caseSensitive: false);
|
||||
final pattern = regex(r'<title[^>]*>(.*?)</title>', caseSensitive: false);
|
||||
final match = pattern.firstMatch(html);
|
||||
return match?.group(1)?.trim();
|
||||
}
|
||||
|
||||
/// 提取favicon URL
|
||||
String? _extractFavicon(String html, String baseUrl) {
|
||||
var pattern = RegExp(
|
||||
var pattern = regex(
|
||||
r'''<link[^>]+rel=["'](?:shortcut )?icon["'][^>]+href=["']([^"']*)["']''',
|
||||
caseSensitive: false,
|
||||
);
|
||||
@@ -190,7 +191,7 @@ String? _extractFavicon(String html, String baseUrl) {
|
||||
return _resolveUrl(match.group(1)!, baseUrl);
|
||||
}
|
||||
|
||||
pattern = RegExp(
|
||||
pattern = regex(
|
||||
r'''<link[^>]+href=["']([^"']*)["'][^>]+rel=["'](?:shortcut )?icon["']''',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 本地通知服务
|
||||
/// 创建时间: 2026-05-02
|
||||
/// 更新时间: 2026-05-13
|
||||
/// 更新时间: 2026-05-17
|
||||
/// 作用: 本地推送通知管理 (初始化/调度/取消/点击处理)
|
||||
/// 上次更新: 增加运势推送路由+E6稍后读提醒
|
||||
/// 上次更新: 鸿蒙适配-增加OhosInitializationSettings
|
||||
/// ============================================================
|
||||
|
||||
import 'dart:io';
|
||||
@@ -18,6 +18,7 @@ import '../../router/app_router.dart';
|
||||
import 'notification_scheduler.dart';
|
||||
import '../../storage/app_kv_store.dart';
|
||||
import '../../utils/logger.dart';
|
||||
import '../../utils/platform_utils.dart' as pu;
|
||||
|
||||
class LocalNotificationService {
|
||||
LocalNotificationService._();
|
||||
@@ -57,13 +58,16 @@ class LocalNotificationService {
|
||||
requestSoundPermission: false,
|
||||
);
|
||||
|
||||
const ohosSettings = OhosInitializationSettings('@mipmap/ic_launcher');
|
||||
|
||||
const settings = InitializationSettings(
|
||||
android: androidSettings,
|
||||
iOS: iosSettings,
|
||||
ohos: ohosSettings,
|
||||
);
|
||||
|
||||
await _plugin.initialize(
|
||||
settings,
|
||||
settings: settings,
|
||||
onDidReceiveNotificationResponse: _onNotificationTapped,
|
||||
);
|
||||
|
||||
@@ -72,6 +76,19 @@ class LocalNotificationService {
|
||||
}
|
||||
|
||||
static Future<bool> requestPermission() async {
|
||||
if (pu.isOhos) {
|
||||
try {
|
||||
final ohos = _plugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
OhosFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
final result = await ohos?.requestNotificationsPermission();
|
||||
return result ?? false;
|
||||
} catch (e) {
|
||||
Log.w('鸿蒙通知权限请求失败: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (Platform.isIOS) {
|
||||
final result = await _plugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
@@ -144,7 +161,13 @@ class LocalNotificationService {
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
await _plugin.show(id, title, body, details, payload: payload);
|
||||
await _plugin.show(
|
||||
id: id,
|
||||
title: title,
|
||||
body: body,
|
||||
notificationDetails: details,
|
||||
payload: payload,
|
||||
);
|
||||
Log.i('即时通知已发送: id=$id title=$title');
|
||||
}
|
||||
|
||||
@@ -175,14 +198,12 @@ class LocalNotificationService {
|
||||
);
|
||||
|
||||
await _plugin.zonedSchedule(
|
||||
id,
|
||||
title,
|
||||
body,
|
||||
scheduledDate,
|
||||
details,
|
||||
id: id,
|
||||
scheduledDate: scheduledDate,
|
||||
notificationDetails: details,
|
||||
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
|
||||
uiLocalNotificationDateInterpretation:
|
||||
UILocalNotificationDateInterpretation.absoluteTime,
|
||||
title: title,
|
||||
body: body,
|
||||
matchDateTimeComponents: DateTimeComponents.time,
|
||||
payload: payload,
|
||||
);
|
||||
@@ -217,14 +238,12 @@ class LocalNotificationService {
|
||||
);
|
||||
|
||||
await _plugin.zonedSchedule(
|
||||
id,
|
||||
title,
|
||||
body,
|
||||
tzTime,
|
||||
details,
|
||||
id: id,
|
||||
scheduledDate: tzTime,
|
||||
notificationDetails: details,
|
||||
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
|
||||
uiLocalNotificationDateInterpretation:
|
||||
UILocalNotificationDateInterpretation.absoluteTime,
|
||||
title: title,
|
||||
body: body,
|
||||
payload: payload,
|
||||
);
|
||||
|
||||
@@ -233,7 +252,7 @@ class LocalNotificationService {
|
||||
}
|
||||
|
||||
static Future<void> cancel(int id) async {
|
||||
await _plugin.cancel(id);
|
||||
await _plugin.cancel(id: id);
|
||||
_pendingRoutes.remove(id);
|
||||
Log.i('通知已取消: id=$id');
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 本地通知服务
|
||||
/// 创建时间: 2026-05-10
|
||||
/// 更新时间: 2026-05-10
|
||||
/// 更新时间: 2026-05-17
|
||||
/// 作用: 管理本地推送通知(每日推荐/签到提醒/即时通知)
|
||||
/// 上次更新: 初始创建
|
||||
/// 上次更新: 鸿蒙适配-增加OhosInitializationSettings
|
||||
/// ============================================================
|
||||
|
||||
import 'dart:io';
|
||||
@@ -13,6 +13,7 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:timezone/data/latest_all.dart' as tz;
|
||||
import 'package:timezone/timezone.dart' as tz;
|
||||
import 'package:xianyan/core/utils/platform_utils.dart' as pu;
|
||||
|
||||
import '../../utils/logger.dart';
|
||||
|
||||
@@ -24,29 +25,16 @@ class NotificationService {
|
||||
|
||||
static bool _initialized = false;
|
||||
|
||||
// ============================================================
|
||||
// SharedPreferences 键名
|
||||
// ============================================================
|
||||
|
||||
static const _keyDailyRecommend = 'notif_daily_recommend';
|
||||
static const _keyDailyRecommendTime = 'notif_daily_recommend_time';
|
||||
static const _keySigninReminder = 'notif_signin_reminder';
|
||||
static const _keySigninReminderTime = 'notif_signin_reminder_time';
|
||||
|
||||
// ============================================================
|
||||
// 通知渠道 ID
|
||||
// ============================================================
|
||||
|
||||
static const _channelDailyId = 'xianyan_daily_recommend';
|
||||
static const _channelDailyName = '每日推荐';
|
||||
static const _channelSigninId = 'xianyan_signin_reminder';
|
||||
static const _channelSigninName = '签到提醒';
|
||||
|
||||
// ============================================================
|
||||
// 初始化
|
||||
// ============================================================
|
||||
|
||||
/// 初始化通知插件,请求权限,配置 Android 通知渠道
|
||||
static Future<bool> init() async {
|
||||
if (_initialized) return true;
|
||||
|
||||
@@ -67,15 +55,17 @@ class NotificationService {
|
||||
requestBadgePermission: false,
|
||||
requestSoundPermission: false,
|
||||
);
|
||||
const ohosSettings = OhosInitializationSettings('@mipmap/ic_launcher');
|
||||
|
||||
const settings = InitializationSettings(
|
||||
android: androidSettings,
|
||||
iOS: iosSettings,
|
||||
macOS: macOsSettings,
|
||||
ohos: ohosSettings,
|
||||
);
|
||||
|
||||
final result = await _plugin.initialize(
|
||||
settings,
|
||||
settings: settings,
|
||||
onDidReceiveNotificationResponse: _onNotificationTapped,
|
||||
);
|
||||
|
||||
@@ -94,9 +84,15 @@ class NotificationService {
|
||||
}
|
||||
}
|
||||
|
||||
/// 请求 iOS/macOS 通知权限
|
||||
static Future<void> _requestPermissions() async {
|
||||
try {
|
||||
if (pu.isOhos) {
|
||||
await _plugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
OhosFlutterLocalNotificationsPlugin
|
||||
>()
|
||||
?.requestNotificationsPermission();
|
||||
}
|
||||
if (Platform.isIOS) {
|
||||
await _plugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
@@ -116,16 +112,10 @@ class NotificationService {
|
||||
}
|
||||
}
|
||||
|
||||
/// 通知点击回调
|
||||
static void _onNotificationTapped(NotificationResponse response) {
|
||||
Log.i('通知被点击: id=${response.id}, payload=${response.payload}');
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 每日推荐通知
|
||||
// ============================================================
|
||||
|
||||
/// 调度每日推荐通知
|
||||
static Future<bool> scheduleDailyRecommend(TimeOfDay time) async {
|
||||
try {
|
||||
if (!_initialized) await init();
|
||||
@@ -144,14 +134,12 @@ class NotificationService {
|
||||
);
|
||||
|
||||
await _plugin.zonedSchedule(
|
||||
1001,
|
||||
'今日推荐 ✨',
|
||||
'新的诗词、成语、名言等你来看',
|
||||
_nextInstanceOfTime(time.hour, time.minute),
|
||||
details,
|
||||
id: 1001,
|
||||
title: '今日推荐 ✨',
|
||||
body: '新的诗词、成语、名言等你来看',
|
||||
scheduledDate: _nextInstanceOfTime(time.hour, time.minute),
|
||||
notificationDetails: details,
|
||||
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
|
||||
uiLocalNotificationDateInterpretation:
|
||||
UILocalNotificationDateInterpretation.absoluteTime,
|
||||
matchDateTimeComponents: DateTimeComponents.time,
|
||||
);
|
||||
|
||||
@@ -164,11 +152,6 @@ class NotificationService {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 签到提醒通知
|
||||
// ============================================================
|
||||
|
||||
/// 调度每日签到提醒
|
||||
static Future<bool> scheduleSigninReminder(TimeOfDay time) async {
|
||||
try {
|
||||
if (!_initialized) await init();
|
||||
@@ -187,14 +170,12 @@ class NotificationService {
|
||||
);
|
||||
|
||||
await _plugin.zonedSchedule(
|
||||
1002,
|
||||
'签到提醒 📝',
|
||||
'别忘了今日签到,连续签到有惊喜哦',
|
||||
_nextInstanceOfTime(time.hour, time.minute),
|
||||
details,
|
||||
id: 1002,
|
||||
title: '签到提醒 📝',
|
||||
body: '别忘了今日签到,连续签到有惊喜哦',
|
||||
scheduledDate: _nextInstanceOfTime(time.hour, time.minute),
|
||||
notificationDetails: details,
|
||||
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
|
||||
uiLocalNotificationDateInterpretation:
|
||||
UILocalNotificationDateInterpretation.absoluteTime,
|
||||
matchDateTimeComponents: DateTimeComponents.time,
|
||||
);
|
||||
|
||||
@@ -207,11 +188,6 @@ class NotificationService {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 取消通知
|
||||
// ============================================================
|
||||
|
||||
/// 取消所有待处理通知
|
||||
static Future<void> cancelAll() async {
|
||||
try {
|
||||
await _plugin.cancelAll();
|
||||
@@ -223,11 +199,6 @@ class NotificationService {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 即时通知
|
||||
// ============================================================
|
||||
|
||||
/// 显示即时通知
|
||||
static Future<bool> showImmediate(String title, String body) async {
|
||||
try {
|
||||
if (!_initialized) await init();
|
||||
@@ -244,7 +215,12 @@ class NotificationService {
|
||||
);
|
||||
|
||||
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
await _plugin.show(now, title, body, details);
|
||||
await _plugin.show(
|
||||
id: now,
|
||||
title: title,
|
||||
body: body,
|
||||
notificationDetails: details,
|
||||
);
|
||||
Log.i('即时通知已发送: $title');
|
||||
return true;
|
||||
} catch (e) {
|
||||
@@ -253,17 +229,11 @@ class NotificationService {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 偏好读取
|
||||
// ============================================================
|
||||
|
||||
/// 每日推荐是否开启
|
||||
static Future<bool> isDailyRecommendEnabled() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_keyDailyRecommend) ?? false;
|
||||
}
|
||||
|
||||
/// 获取每日推荐时间
|
||||
static Future<TimeOfDay?> getDailyRecommendTime() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final saved = prefs.getString(_keyDailyRecommendTime);
|
||||
@@ -271,13 +241,11 @@ class NotificationService {
|
||||
return _parseTimeOfDay(saved);
|
||||
}
|
||||
|
||||
/// 签到提醒是否开启
|
||||
static Future<bool> isSigninReminderEnabled() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_keySigninReminder) ?? false;
|
||||
}
|
||||
|
||||
/// 获取签到提醒时间
|
||||
static Future<TimeOfDay?> getSigninReminderTime() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final saved = prefs.getString(_keySigninReminderTime);
|
||||
@@ -285,10 +253,6 @@ class NotificationService {
|
||||
return _parseTimeOfDay(saved);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 偏好持久化
|
||||
// ============================================================
|
||||
|
||||
static Future<void> _saveDailyRecommendPrefs(
|
||||
bool enabled,
|
||||
TimeOfDay? time,
|
||||
@@ -315,11 +279,6 @@ class NotificationService {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 工具方法
|
||||
// ============================================================
|
||||
|
||||
/// 计算下一次目标时间的 tz.TZDateTime
|
||||
static tz.TZDateTime _nextInstanceOfTime(int hour, int minute) {
|
||||
final now = tz.TZDateTime.now(tz.local);
|
||||
var scheduled = tz.TZDateTime(
|
||||
@@ -336,11 +295,9 @@ class NotificationService {
|
||||
return scheduled;
|
||||
}
|
||||
|
||||
/// TimeOfDay → "HH:mm" 字符串
|
||||
static String _formatTimeOfDay(TimeOfDay t) =>
|
||||
'${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}';
|
||||
|
||||
/// "HH:mm" 字符串 → TimeOfDay
|
||||
static TimeOfDay _parseTimeOfDay(String s) {
|
||||
final parts = s.split(':');
|
||||
return TimeOfDay(hour: int.parse(parts[0]), minute: int.parse(parts[1]));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 统一分享接收服务
|
||||
/// 创建时间: 2026-05-15
|
||||
/// 更新时间: 2026-05-15
|
||||
/// 更新时间: 2026-05-16
|
||||
/// 作用: 接收其他App通过系统分享面板发送的内容,写入稍后读会话
|
||||
/// 上次更新: v13.0.0 新增分享唤起自动导航到稍后读会话
|
||||
/// ============================================================
|
||||
@@ -13,6 +13,8 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
|
||||
import 'package:xianyan/core/utils/pattern_utils.dart';
|
||||
import 'package:xianyan/core/utils/platform_utils.dart' as pu;
|
||||
|
||||
import '../../../core/utils/logger.dart';
|
||||
import '../../../core/router/app_router.dart';
|
||||
@@ -28,15 +30,11 @@ class SharingReceiverService {
|
||||
|
||||
static const String _readLaterConvId = 'readlater';
|
||||
|
||||
static final RegExp _urlRegex = RegExp(r'^https?://', caseSensitive: false);
|
||||
static final _urlRegex = regex(r'^https?://', caseSensitive: false);
|
||||
|
||||
StreamSubscription<List<SharedMediaFile>>? _mediaSub;
|
||||
|
||||
GlobalKey<NavigatorState>? _navigatorKey;
|
||||
|
||||
void setNavigatorKey(GlobalKey<NavigatorState> key) {
|
||||
_navigatorKey = key;
|
||||
}
|
||||
void setNavigatorKey(GlobalKey<NavigatorState> key) {}
|
||||
|
||||
/// 初始化分享监听,在main.dart中调用
|
||||
Future<void> init() async {
|
||||
@@ -46,7 +44,7 @@ class SharingReceiverService {
|
||||
_initWeb();
|
||||
} else if (Platform.isWindows) {
|
||||
_initWindows();
|
||||
} else {
|
||||
} else if (pu.isOhos || Platform.isAndroid || Platform.isIOS) {
|
||||
await _initMobile();
|
||||
}
|
||||
|
||||
|
||||
@@ -96,10 +96,24 @@ class SmartModeService {
|
||||
}
|
||||
}
|
||||
|
||||
final browseModeProvider = StateProvider<BrowseMode>((ref) {
|
||||
return SmartModeService.currentMode;
|
||||
});
|
||||
final browseModeProvider = NotifierProvider<BrowseModeNotifier, BrowseMode>(
|
||||
BrowseModeNotifier.new,
|
||||
);
|
||||
|
||||
final isAutoModeProvider = StateProvider<bool>((ref) {
|
||||
return SmartModeService.isAutoMode;
|
||||
});
|
||||
class BrowseModeNotifier extends Notifier<BrowseMode> {
|
||||
@override
|
||||
BrowseMode build() => SmartModeService.currentMode;
|
||||
|
||||
set state(BrowseMode value) => super.state = value;
|
||||
}
|
||||
|
||||
final isAutoModeProvider = NotifierProvider<IsAutoModeNotifier, bool>(
|
||||
IsAutoModeNotifier.new,
|
||||
);
|
||||
|
||||
class IsAutoModeNotifier extends Notifier<bool> {
|
||||
@override
|
||||
bool build() => SmartModeService.isAutoMode;
|
||||
|
||||
set state(bool value) => super.state = value;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — Drift 数据库定义
|
||||
/// 创建时间: 2026-04-20
|
||||
/// 更新时间: 2026-05-13
|
||||
/// 更新时间: 2026-05-17
|
||||
/// 作用: 本地 SQLite 数据库表结构定义 (Drift)
|
||||
/// 上次更新: v14 新增运势记录表(FortuneRecords)
|
||||
/// 上次更新: 鸿蒙适配-条件导入增加OpenHarmony分支
|
||||
/// ============================================================
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
@@ -546,7 +546,8 @@ class FortuneRecords extends Table {
|
||||
TextColumn get unsuitableJson => text().withDefault(const Constant('[]'))();
|
||||
TextColumn get theme => text().withDefault(const Constant('ancient'))();
|
||||
BoolColumn get isToday => boolean().withDefault(const Constant(false))();
|
||||
BoolColumn get canRegenerate => boolean().withDefault(const Constant(false))();
|
||||
BoolColumn get canRegenerate =>
|
||||
boolean().withDefault(const Constant(false))();
|
||||
IntColumn get regenCount => integer().withDefault(const Constant(0))();
|
||||
TextColumn get imageUrl => text().withDefault(const Constant(''))();
|
||||
TextColumn get huangliJson => text().withDefault(const Constant('{}'))();
|
||||
@@ -1870,11 +1871,15 @@ class AppDatabase extends _$AppDatabase {
|
||||
// ---- 运势记录 CRUD ----
|
||||
|
||||
Future<void> insertFortuneRecord(FortuneRecordsCompanion record) {
|
||||
return into(fortuneRecords).insert(record, mode: InsertMode.insertOrReplace);
|
||||
return into(
|
||||
fortuneRecords,
|
||||
).insert(record, mode: InsertMode.insertOrReplace);
|
||||
}
|
||||
|
||||
Future<FortuneRecord?> getFortuneRecord(String date) {
|
||||
return (select(fortuneRecords)..where((t) => t.date.equals(date))).getSingleOrNull();
|
||||
return (select(
|
||||
fortuneRecords,
|
||||
)..where((t) => t.date.equals(date))).getSingleOrNull();
|
||||
}
|
||||
|
||||
Future<List<FortuneRecord>> getFortuneRecords({
|
||||
@@ -1887,17 +1892,19 @@ class AppDatabase extends _$AppDatabase {
|
||||
query = query..where((t) => t.uid.equals(uid));
|
||||
}
|
||||
return (query
|
||||
..orderBy([
|
||||
(t) => sortOrder == 'ASC'
|
||||
? OrderingTerm.asc(t.createdAt)
|
||||
: OrderingTerm.desc(t.createdAt),
|
||||
])
|
||||
..limit(limit))
|
||||
..orderBy([
|
||||
(t) => sortOrder == 'ASC'
|
||||
? OrderingTerm.asc(t.createdAt)
|
||||
: OrderingTerm.desc(t.createdAt),
|
||||
])
|
||||
..limit(limit))
|
||||
.get();
|
||||
}
|
||||
|
||||
Future<void> updateFortuneRecord(FortuneRecordsCompanion record) {
|
||||
return (update(fortuneRecords)..where((t) => t.date.equals(record.date.value))).write(record);
|
||||
return (update(
|
||||
fortuneRecords,
|
||||
)..where((t) => t.date.equals(record.date.value))).write(record);
|
||||
}
|
||||
|
||||
Future<void> updateFortuneNote(String date, String note) {
|
||||
@@ -1915,7 +1922,8 @@ class AppDatabase extends _$AppDatabase {
|
||||
}
|
||||
|
||||
Future<int> getFortuneRecordCount({String? uid}) async {
|
||||
final query = selectOnly(fortuneRecords)..addColumns([fortuneRecords.date.count()]);
|
||||
final query = selectOnly(fortuneRecords)
|
||||
..addColumns([fortuneRecords.date.count()]);
|
||||
if (uid != null) {
|
||||
query.where(fortuneRecords.uid.equals(uid));
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — Drift原生数据库连接 (Android/iOS/macOS/Windows/Linux)
|
||||
/// 闲言APP — Drift原生数据库连接 (Android/iOS/macOS/Windows/Linux/OpenHarmony)
|
||||
/// 创建时间: 2026-04-25
|
||||
/// 更新时间: 2026-04-25
|
||||
/// 作用: 原生平台使用SQLite (drift/native)
|
||||
/// 上次更新: 初始创建
|
||||
/// 更新时间: 2026-05-17
|
||||
/// 作用: 原生平台数据库连接,OpenHarmony 使用 sqflite_ohos 桥接
|
||||
/// 上次更新: 鸿蒙适配-运行时检测平台,OpenHarmony走sqflite后端
|
||||
/// ============================================================
|
||||
|
||||
import 'dart:io';
|
||||
@@ -13,8 +13,16 @@ import 'package:drift/native.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:sqlite3_flutter_libs/sqlite3_flutter_libs.dart';
|
||||
import 'package:xianyan/core/utils/logger.dart';
|
||||
|
||||
import 'ohos.dart';
|
||||
|
||||
QueryExecutor openConnection() {
|
||||
if (Platform.operatingSystem == 'ohos') {
|
||||
Log.i('Drift: 检测到 OpenHarmony 平台,使用 sqflite_ohos 后端');
|
||||
return openOhosConnection();
|
||||
}
|
||||
|
||||
return LazyDatabase(() async {
|
||||
final dbFolder = await getApplicationDocumentsDirectory();
|
||||
final file = File(p.join(dbFolder.path, 'xianyan.db'));
|
||||
|
||||
27
lib/core/storage/database/database_connection/ohos.dart
Normal file
@@ -0,0 +1,27 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — Drift OpenHarmony 数据库连接
|
||||
/// 创建时间: 2026-05-17
|
||||
/// 更新时间: 2026-05-17
|
||||
/// 作用: OpenHarmony 平台使用 sqflite_ohos 作为 Drift 后端
|
||||
/// 通过 SqfliteDelegate 桥接 sqflite → Drift DatabaseDelegate
|
||||
/// 上次更新: 修复导入路径
|
||||
/// ============================================================
|
||||
|
||||
import 'package:drift/backends.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:sqflite/sqflite.dart' as sqflite;
|
||||
import 'package:xianyan/core/utils/logger.dart';
|
||||
|
||||
import 'sqflite_delegate.dart';
|
||||
|
||||
QueryExecutor openOhosConnection() {
|
||||
return LazyDatabase(() async {
|
||||
final databasesPath = await sqflite.getDatabasesPath();
|
||||
final dbPath = p.join(databasesPath, 'xianyan.db');
|
||||
|
||||
Log.i('Drift-OpenHarmony: 数据库路径 = $dbPath');
|
||||
|
||||
return DelegatedDatabase(SqfliteDelegate(dbPath, 14), isSequential: true);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — SqfliteDelegate 适配器
|
||||
/// 创建时间: 2026-05-17
|
||||
/// 更新时间: 2026-05-17
|
||||
/// 作用: 将 sqflite 桥接为 Drift 的 DatabaseDelegate
|
||||
/// 用于 OpenHarmony 等不支持 dart:ffi 直接加载 SQLite 的平台
|
||||
/// 上次更新: 修复SQL双引号标识符兼容性 — OpenHarmony RDB不支持双引号标识符
|
||||
/// ============================================================
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:drift/backends.dart';
|
||||
import 'package:sqflite/sqflite.dart' as sqflite;
|
||||
|
||||
class SqfliteDelegate extends DatabaseDelegate {
|
||||
sqflite.Database? _db;
|
||||
final String _path;
|
||||
final int _version;
|
||||
|
||||
SqfliteDelegate(this._path, this._version);
|
||||
|
||||
sqflite.Database get db => _db!;
|
||||
|
||||
@override
|
||||
TransactionDelegate get transactionDelegate =>
|
||||
_SqfliteTransactionDelegate(this);
|
||||
|
||||
@override
|
||||
DbVersionDelegate get versionDelegate => _SqfliteVersionDelegate(this);
|
||||
|
||||
@override
|
||||
FutureOr<bool> get isOpen => _db?.isOpen ?? false;
|
||||
|
||||
@override
|
||||
Future<void> open(QueryExecutorUser user) async {
|
||||
_db = await sqflite.openDatabase(_path, version: _version);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
await _db?.close();
|
||||
_db = null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<QueryResult> runSelect(String statement, List<Object?> args) async {
|
||||
final sql = _adaptSql(statement);
|
||||
final rows = await _db!.rawQuery(sql, args);
|
||||
return QueryResult.fromRows(rows.cast<Map<String, dynamic>>());
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> runInsert(String statement, List<Object?> args) async {
|
||||
final sql = _adaptSql(statement);
|
||||
return await _db!.rawInsert(sql, args);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> runUpdate(String statement, List<Object?> args) async {
|
||||
final sql = _adaptSql(statement);
|
||||
return await _db!.rawUpdate(sql, args);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> runCustom(String statement, List<Object?> args) async {
|
||||
final sql = _adaptSql(statement);
|
||||
await _db!.execute(sql, args);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> runBatched(BatchedStatements statements) async {
|
||||
final batch = _db!.batch();
|
||||
for (final application in statements.arguments) {
|
||||
final rawSql = statements.statements[application.statementIndex];
|
||||
final sql = _adaptSql(rawSql);
|
||||
final args = application.arguments;
|
||||
final upperSql = sql.trimLeft().toUpperCase();
|
||||
|
||||
if (upperSql.startsWith('SELECT')) {
|
||||
batch.rawQuery(sql, args);
|
||||
} else if (upperSql.startsWith('INSERT')) {
|
||||
batch.rawInsert(sql, args);
|
||||
} else if (upperSql.startsWith('UPDATE')) {
|
||||
batch.rawUpdate(sql, args);
|
||||
} else if (upperSql.startsWith('DELETE')) {
|
||||
batch.rawUpdate(sql, args);
|
||||
} else {
|
||||
batch.execute(sql, args);
|
||||
}
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
||||
/// OpenHarmony RDB 引擎不支持双引号标识符(如 "table_name"、"column_name")
|
||||
/// Drift 生成的 SQL 使用双引号包裹标识符,需要去除
|
||||
/// 例如: SELECT * FROM "sentences" → SELECT * FROM sentences
|
||||
/// INSERT INTO "sentences" ("id", "content") → INSERT INTO sentences (id, content)
|
||||
///
|
||||
/// 注意: 不能简单替换所有双引号,因为字符串值中也包含双引号
|
||||
/// 使用正则匹配 SQL 标识符位置的双引号(紧跟在 . 或空白或 ( 后面的 "...")
|
||||
static String _adaptSql(String sql) {
|
||||
final buffer = StringBuffer();
|
||||
int i = 0;
|
||||
final len = sql.length;
|
||||
|
||||
while (i < len) {
|
||||
if (sql[i] == "'") {
|
||||
buffer.write("'");
|
||||
i++;
|
||||
while (i < len) {
|
||||
if (sql[i] == "'") {
|
||||
buffer.write("'");
|
||||
i++;
|
||||
if (i < len && sql[i] == "'") {
|
||||
buffer.write("'");
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
buffer.write(sql[i]);
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (sql[i] == '"') {
|
||||
final end = sql.indexOf('"', i + 1);
|
||||
if (end != -1) {
|
||||
buffer.write(sql.substring(i + 1, end));
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
buffer.write(sql[i]);
|
||||
i++;
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
}
|
||||
|
||||
class _SqfliteVersionDelegate extends DynamicVersionDelegate {
|
||||
final SqfliteDelegate _delegate;
|
||||
|
||||
_SqfliteVersionDelegate(this._delegate);
|
||||
|
||||
@override
|
||||
Future<int> get schemaVersion async {
|
||||
final result = await _delegate._db!.rawQuery('PRAGMA user_version');
|
||||
return (result.first['user_version'] as int?) ?? 0;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setSchemaVersion(int version) async {
|
||||
await _delegate._db!.execute('PRAGMA user_version = $version');
|
||||
}
|
||||
}
|
||||
|
||||
class _SqfliteTransactionDelegate extends SupportedTransactionDelegate {
|
||||
final SqfliteDelegate _delegate;
|
||||
|
||||
_SqfliteTransactionDelegate(this._delegate);
|
||||
|
||||
@override
|
||||
bool get managesLockInternally => true;
|
||||
|
||||
@override
|
||||
FutureOr<void> startTransaction(Future<void> Function(QueryDelegate) run) {
|
||||
return _delegate._db!.transaction((txn) async {
|
||||
final queryDelegate = _SqfliteTransactionQueryDelegate(txn);
|
||||
await run(queryDelegate);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _SqfliteTransactionQueryDelegate extends QueryDelegate {
|
||||
final sqflite.Transaction _txn;
|
||||
|
||||
_SqfliteTransactionQueryDelegate(this._txn);
|
||||
|
||||
@override
|
||||
Future<QueryResult> runSelect(String statement, List<Object?> args) async {
|
||||
final sql = SqfliteDelegate._adaptSql(statement);
|
||||
final rows = await _txn.rawQuery(sql, args);
|
||||
return QueryResult.fromRows(rows.cast<Map<String, dynamic>>());
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> runInsert(String statement, List<Object?> args) async {
|
||||
final sql = SqfliteDelegate._adaptSql(statement);
|
||||
return await _txn.rawInsert(sql, args);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> runUpdate(String statement, List<Object?> args) async {
|
||||
final sql = SqfliteDelegate._adaptSql(statement);
|
||||
return await _txn.rawUpdate(sql, args);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> runCustom(String statement, List<Object?> args) async {
|
||||
final sql = SqfliteDelegate._adaptSql(statement);
|
||||
await _txn.execute(sql, args);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
/// ============================================================
|
||||
/// ============================================================
|
||||
/// 闲言APP — 安全存储
|
||||
/// 创建时间: 2026-04-20
|
||||
/// 更新时间: 2026-04-20
|
||||
@@ -33,13 +33,8 @@ class SecureStorage {
|
||||
accessibility: KeychainAccessibility.first_unlock_this_device,
|
||||
);
|
||||
|
||||
static const _androidOptions = AndroidOptions(
|
||||
encryptedSharedPreferences: true,
|
||||
);
|
||||
|
||||
static const FlutterSecureStorage _storage = FlutterSecureStorage(
|
||||
iOptions: _iosOptions,
|
||||
aOptions: _androidOptions,
|
||||
);
|
||||
|
||||
// ============================================================
|
||||
@@ -57,8 +52,7 @@ class SecureStorage {
|
||||
static Future<void> delete(String key) => _storage.delete(key: key);
|
||||
|
||||
/// 是否包含
|
||||
static Future<bool> containsKey(String key) =>
|
||||
_storage.containsKey(key: key);
|
||||
static Future<bool> containsKey(String key) => _storage.containsKey(key: key);
|
||||
|
||||
/// 清空所有
|
||||
static Future<void> deleteAll() => _storage.deleteAll();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 主题系统
|
||||
/// 创建时间: 2026-04-20
|
||||
/// 更新时间: 2026-05-13
|
||||
/// 更新时间: 2026-05-16
|
||||
/// 作用: 统一 ThemeData 配置,整合设计令牌,支持日/夜切换
|
||||
/// 上次更新: 补充语义色(success/error/warning/info/destructive)默认值改为CupertinoColors系统色
|
||||
/// ============================================================
|
||||
@@ -232,7 +232,11 @@ class AppThemeExtension extends ThemeExtension<AppThemeExtension> {
|
||||
errorColor: Color.lerp(errorColor, other.errorColor, t)!,
|
||||
warningColor: Color.lerp(warningColor, other.warningColor, t)!,
|
||||
infoColor: Color.lerp(infoColor, other.infoColor, t)!,
|
||||
destructiveColor: Color.lerp(destructiveColor, other.destructiveColor, t)!,
|
||||
destructiveColor: Color.lerp(
|
||||
destructiveColor,
|
||||
other.destructiveColor,
|
||||
t,
|
||||
)!,
|
||||
textOnAccent: Color.lerp(textOnAccent, other.textOnAccent, t)!,
|
||||
isAmoled: t < 0.5 ? isAmoled : other.isAmoled,
|
||||
glassBlurMultiplier: lerpDouble(
|
||||
@@ -700,9 +704,10 @@ class AppTheme {
|
||||
String fontFamily = 'Inter',
|
||||
]) {
|
||||
FontWeight adjustWeight(FontWeight target) {
|
||||
final diff = baseWeight.index - FontWeight.w400.index;
|
||||
final newIndex = (target.index + diff).clamp(0, 8);
|
||||
return FontWeight.values[newIndex];
|
||||
final diff = baseWeight.value - FontWeight.w400.value;
|
||||
final newValue = (target.value + diff).clamp(100, 900);
|
||||
final idx = (newValue ~/ 100) - 1;
|
||||
return FontWeight.values[idx.clamp(0, 8)];
|
||||
}
|
||||
|
||||
return TextTheme(
|
||||
|
||||
51
lib/core/utils/clipboard_bridge.dart
Normal file
@@ -0,0 +1,51 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 鸿蒙剪贴板桥接工具
|
||||
/// 创建时间: 2026-05-17
|
||||
/// 更新时间: 2026-05-17
|
||||
/// 作用: 在鸿蒙平台上通过原生MethodChannel读取剪贴板,
|
||||
/// 替代Flutter Clipboard.getData以避免READ_PASTEBOARD受限ACL权限
|
||||
/// 上次更新: 修复_isOhos检测逻辑,使用platform_utils.isOhos
|
||||
/// ============================================================
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:xianyan/core/utils/platform_utils.dart' as pu;
|
||||
|
||||
class ClipboardBridge {
|
||||
ClipboardBridge._();
|
||||
|
||||
static const _channel = MethodChannel('plugins.flutter.io/clipboard_ohos');
|
||||
|
||||
static bool get _isOhos => pu.isOhos;
|
||||
|
||||
static Future<String?> getData() async {
|
||||
if (_isOhos) {
|
||||
try {
|
||||
final result = await _channel.invokeMethod<String>('Clipboard.getData');
|
||||
return result;
|
||||
} on MissingPluginException {
|
||||
// ohos channel not available, fallback
|
||||
} on PlatformException {
|
||||
// permission or other error, fallback
|
||||
}
|
||||
}
|
||||
final data = await Clipboard.getData(Clipboard.kTextPlain);
|
||||
return data?.text;
|
||||
}
|
||||
|
||||
static Future<bool> hasStrings() async {
|
||||
if (_isOhos) {
|
||||
try {
|
||||
final result = await _channel.invokeMethod<bool>(
|
||||
'Clipboard.hasStrings',
|
||||
);
|
||||
return result ?? false;
|
||||
} on MissingPluginException {
|
||||
// fallback
|
||||
} on PlatformException {
|
||||
// fallback
|
||||
}
|
||||
}
|
||||
final data = await Clipboard.getData(Clipboard.kTextPlain);
|
||||
return data?.text?.isNotEmpty ?? false;
|
||||
}
|
||||
}
|
||||
@@ -132,7 +132,7 @@ class DeviceDetection {
|
||||
static bool _isHarmonyOS() {
|
||||
try {
|
||||
if (pu.isWeb) return false;
|
||||
return false;
|
||||
return pu.isOhos;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 通用扩展方法
|
||||
/// 创建时间: 2026-04-20
|
||||
/// 更新时间: 2026-04-20
|
||||
/// 更新时间: 2026-05-16
|
||||
/// 作用: String / BuildContext / Color 等常用扩展
|
||||
/// 上次更新: 初始创建
|
||||
/// 上次更新: RegExp() → regex() 消除 Dart 3.11 废弃警告
|
||||
/// ============================================================
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:xianyan/core/utils/pattern_utils.dart';
|
||||
|
||||
import 'device_detection.dart';
|
||||
|
||||
@@ -38,25 +39,19 @@ extension StringX on String {
|
||||
String get stripHtml {
|
||||
if (isEmpty) return this;
|
||||
var result = this;
|
||||
result = result.replaceAll(regex(r'<br\s*/?>', caseSensitive: false), '\n');
|
||||
result = result.replaceAll(regex(r'<p\s*/?>', caseSensitive: false), '\n');
|
||||
result = result.replaceAll(regex(r'</p>', caseSensitive: false), '');
|
||||
result = result.replaceAll(
|
||||
RegExp(r'<br\s*/?>', caseSensitive: false),
|
||||
'\n',
|
||||
);
|
||||
result = result.replaceAll(
|
||||
RegExp(r'<p\s*/?>', caseSensitive: false),
|
||||
'\n',
|
||||
);
|
||||
result = result.replaceAll(RegExp(r'</p>', caseSensitive: false), '');
|
||||
result = result.replaceAll(
|
||||
RegExp(r'<strong[^>]*>(.*?)</strong>', caseSensitive: false, dotAll: true),
|
||||
regex(r'<strong[^>]*>(.*?)</strong>', caseSensitive: false, dotAll: true),
|
||||
r'$1',
|
||||
);
|
||||
result = result.replaceAll(
|
||||
RegExp(r'<em[^>]*>(.*?)</em>', caseSensitive: false, dotAll: true),
|
||||
regex(r'<em[^>]*>(.*?)</em>', caseSensitive: false, dotAll: true),
|
||||
r'$1',
|
||||
);
|
||||
result = result.replaceAll(RegExp(r'<[^>]*>'), '');
|
||||
result = result.replaceAll(RegExp(r'\n{3,}'), '\n\n');
|
||||
result = result.replaceAll(regex(r'<[^>]*>'), '');
|
||||
result = result.replaceAll(regex(r'\n{3,}'), '\n\n');
|
||||
return result.trim();
|
||||
}
|
||||
|
||||
|
||||
@@ -190,7 +190,7 @@ class Log {
|
||||
static Future<void> shareLogs({LogLevel level = LogLevel.all}) async {
|
||||
try {
|
||||
final path = await exportToFile(level: level);
|
||||
await Share.shareXFiles([XFile(path)], text: '闲言APP 日志文件');
|
||||
await SharePlus.instance.share(ShareParams(files: [XFile(path)], text: '闲言APP 日志文件'));
|
||||
} catch (e) {
|
||||
appLogger.e('日志分享失败', error: e);
|
||||
}
|
||||
|
||||
48
lib/core/utils/pattern_utils.dart
Normal file
@@ -0,0 +1,48 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 正则表达式工具函数
|
||||
/// 创建时间: 2026-05-16
|
||||
/// 更新时间: 2026-05-16
|
||||
/// 作用: 封装 RegExp 构造,消除 Dart 3.11+ 废弃警告
|
||||
/// Dart 3.11 中 RegExp 类被标记为将变为 final(不可继承),
|
||||
/// 所有 RegExp 引用均触发 deprecated 警告。
|
||||
/// 通过工厂函数集中管理,仅本文件忽略废弃警告。
|
||||
/// 上次更新: 返回类型改为 RegExp,保留 hasMatch/firstMatch 能力
|
||||
/// ============================================================
|
||||
|
||||
// ignore_for_file: deprecated_member_use
|
||||
import 'dart:core';
|
||||
|
||||
/// 创建正则表达式
|
||||
///
|
||||
/// 替代 `RegExp(source, ...)` 构造调用,消除 Dart 3.11+ 的
|
||||
/// `deprecated_member_use` 警告。返回 RegExp 以保留
|
||||
/// `hasMatch` / `firstMatch` / `allMatches` 等方法。
|
||||
///
|
||||
/// 示例:
|
||||
/// ```dart
|
||||
/// // 旧写法(触发警告):
|
||||
/// text.replaceAll(RegExp(r'<[^>]*>'), '')
|
||||
/// RegExp(r'\d+').hasMatch(text)
|
||||
///
|
||||
/// // 新写法(无警告):
|
||||
/// text.replaceAll(regex(r'<[^>]*>'), '')
|
||||
/// regex(r'\d+').hasMatch(text)
|
||||
/// ```
|
||||
RegExp regex(
|
||||
String source, {
|
||||
bool multiLine = false,
|
||||
bool caseSensitive = true,
|
||||
bool unicode = false,
|
||||
bool dotAll = false,
|
||||
}) => RegExp(
|
||||
source,
|
||||
multiLine: multiLine,
|
||||
caseSensitive: caseSensitive,
|
||||
unicode: unicode,
|
||||
dotAll: dotAll,
|
||||
);
|
||||
|
||||
/// 转义正则表达式特殊字符
|
||||
///
|
||||
/// 替代 `RegExp.escape(text)`,消除 Dart 3.11+ 的废弃警告。
|
||||
String regexEscape(String text) => RegExp.escape(text);
|
||||
@@ -1,23 +1,33 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 平台IO原生实现 (Android/iOS/macOS/Windows/Linux)
|
||||
/// 闲言APP — 平台IO原生实现 (Android/iOS/macOS/Windows/Linux/Ohos)
|
||||
/// 创建时间: 2026-04-25
|
||||
/// 更新时间: 2026-04-25
|
||||
/// 作用: 原生平台使用dart:io获取平台信息
|
||||
/// 上次更新: 初始创建
|
||||
/// 更新时间: 2026-05-17
|
||||
/// 作用: 原生平台使用dart:io获取平台信息,支持鸿蒙检测
|
||||
/// 上次更新: 增加isOhosImpl鸿蒙平台检测,Platform.operatingSystem=='ohos'
|
||||
/// ============================================================
|
||||
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
bool _isOhos() {
|
||||
try {
|
||||
return Platform.operatingSystem == 'ohos';
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool get isWebImpl => false;
|
||||
bool get isAndroidImpl => Platform.isAndroid;
|
||||
bool get isOhosImpl => _isOhos();
|
||||
bool get isAndroidImpl => Platform.isAndroid && !_isOhos();
|
||||
bool get isIOSImpl => Platform.isIOS;
|
||||
bool get isMacOSImpl => Platform.isMacOS;
|
||||
bool get isWindowsImpl => Platform.isWindows;
|
||||
bool get isLinuxImpl => Platform.isLinux;
|
||||
bool get isMobileImpl => Platform.isAndroid || Platform.isIOS;
|
||||
bool get isMobileImpl => Platform.isAndroid || Platform.isIOS || _isOhos();
|
||||
bool get isDesktopImpl =>
|
||||
Platform.isMacOS || Platform.isWindows || Platform.isLinux;
|
||||
String get platformNameImpl {
|
||||
if (_isOhos()) return 'HarmonyOS';
|
||||
if (Platform.isAndroid) return 'Android';
|
||||
if (Platform.isIOS) return 'iOS';
|
||||
if (Platform.isMacOS) return 'macOS';
|
||||
@@ -28,6 +38,6 @@ String get platformNameImpl {
|
||||
|
||||
bool get supportsFilesystemImpl => true;
|
||||
bool get supportsGPU3DImpl =>
|
||||
Platform.isAndroid || Platform.isIOS || Platform.isMacOS;
|
||||
Platform.isAndroid || Platform.isIOS || Platform.isMacOS || _isOhos();
|
||||
bool get supportsWebView3DImpl =>
|
||||
Platform.isAndroid || Platform.isIOS || Platform.isMacOS;
|
||||
Platform.isAndroid || Platform.isIOS || Platform.isMacOS || _isOhos();
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 平台IO Web Stub
|
||||
/// 创建时间: 2026-04-25
|
||||
/// 更新时间: 2026-04-25
|
||||
/// 更新时间: 2026-05-17
|
||||
/// 作用: Web端不使用dart:io,提供空实现
|
||||
/// 上次更新: 初始创建
|
||||
/// 上次更新: 增加isOhosImpl=false
|
||||
/// ============================================================
|
||||
|
||||
bool get isWebImpl => true;
|
||||
bool get isOhosImpl => false;
|
||||
bool get isAndroidImpl => false;
|
||||
bool get isIOSImpl => false;
|
||||
bool get isMacOSImpl => false;
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 平台工具类
|
||||
/// 创建时间: 2026-04-25
|
||||
/// 更新时间: 2026-04-25
|
||||
/// 作用: 封装平台相关操作,隔离dart:io/dart:html
|
||||
/// 上次更新: 初始创建
|
||||
/// 更新时间: 2026-05-17
|
||||
/// 作用: 封装平台相关操作,隔离dart:io/dart:html,支持鸿蒙
|
||||
/// 上次更新: 增加isOhos鸿蒙平台检测
|
||||
/// ============================================================
|
||||
|
||||
import 'platform_io_stub.dart'
|
||||
if (dart.library.io) 'platform_io_native.dart';
|
||||
|
||||
bool get isWeb => isWebImpl;
|
||||
bool get isOhos => isOhosImpl;
|
||||
bool get isAndroid => isAndroidImpl;
|
||||
bool get isIOS => isIOSImpl;
|
||||
bool get isMacOS => isMacOSImpl;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// ============================================================
|
||||
// ============================================================
|
||||
// 闲言APP — 迷你编辑器全屏页
|
||||
// 创建时间: 2026-04-20
|
||||
// 更新时间: 2026-04-21
|
||||
@@ -40,22 +40,22 @@ class _MiniEditorPageState extends ConsumerState<MiniEditorPage> {
|
||||
final GlobalKey _exportKey = GlobalKey();
|
||||
int _activeTab = 0;
|
||||
|
||||
late final MiniEditorParams _params;
|
||||
// ignore: unused_field
|
||||
late final MiniEditorParams _params = MiniEditorParams(
|
||||
initialText: widget.initialText,
|
||||
initialBackground: widget.initialBackground,
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_params = MiniEditorParams(
|
||||
initialText: widget.initialText,
|
||||
initialBackground: widget.initialBackground,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ext = AppTheme.ext(context);
|
||||
final state = ref.watch(miniEditorProvider(_params));
|
||||
final notifier = ref.read(miniEditorProvider(_params).notifier);
|
||||
final state = ref.watch(miniEditorProvider);
|
||||
final notifier = ref.read(miniEditorProvider.notifier);
|
||||
|
||||
return CupertinoPageScaffold(
|
||||
backgroundColor: ext.bgPrimary,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// ============================================================
|
||||
// ============================================================
|
||||
// 闲言APP — 迷你编辑器半屏弹
|
||||
// 创建时间: 2026-04-20
|
||||
// 更新时间: 2026-04-21
|
||||
@@ -47,22 +47,22 @@ class _MiniEditorSheetState extends ConsumerState<MiniEditorSheet> {
|
||||
final GlobalKey _exportKey = GlobalKey();
|
||||
int _activeTab = 0;
|
||||
|
||||
late final MiniEditorParams _params;
|
||||
// ignore: unused_field
|
||||
late final MiniEditorParams _params = MiniEditorParams(
|
||||
initialText: widget.initialText,
|
||||
initialBackground: widget.initialBackground,
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_params = MiniEditorParams(
|
||||
initialText: widget.initialText,
|
||||
initialBackground: widget.initialBackground,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ext = AppTheme.ext(context);
|
||||
final state = ref.watch(miniEditorProvider(_params));
|
||||
final notifier = ref.read(miniEditorProvider(_params).notifier);
|
||||
final state = ref.watch(miniEditorProvider);
|
||||
final notifier = ref.read(miniEditorProvider.notifier);
|
||||
|
||||
return Container(
|
||||
constraints: BoxConstraints(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// ============================================================
|
||||
// ============================================================
|
||||
// 闲言APP — 迷你编辑器内嵌组件
|
||||
// 创建时间: 2026-04-20
|
||||
// 更新时间: 2026-04-21
|
||||
@@ -54,22 +54,22 @@ class _MiniEditorWidgetState extends ConsumerState<MiniEditorWidget> {
|
||||
final GlobalKey _exportKey = GlobalKey();
|
||||
int _activeTab = 0;
|
||||
|
||||
late final MiniEditorParams _params;
|
||||
// ignore: unused_field
|
||||
late final MiniEditorParams _params = MiniEditorParams(
|
||||
initialText: widget.initialText,
|
||||
initialBackground: widget.initialBackground,
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_params = MiniEditorParams(
|
||||
initialText: widget.initialText,
|
||||
initialBackground: widget.initialBackground,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ext = AppTheme.ext(context);
|
||||
final state = ref.watch(miniEditorProvider(_params));
|
||||
final notifier = ref.read(miniEditorProvider(_params).notifier);
|
||||
final state = ref.watch(miniEditorProvider);
|
||||
final notifier = ref.read(miniEditorProvider.notifier);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
||||
@@ -85,7 +85,7 @@ class ProEditorPageState extends State<ProEditorPage>
|
||||
bool _showHistorySlider = true;
|
||||
bool _showThemePanel = false;
|
||||
DragBorderStyle _dragBorderStyle = DragBorderStyle.dashed;
|
||||
CanvasStyleModel _canvasStyle = CanvasStyleModel.defaults;
|
||||
CanvasStyleModel _canvasStyle = CanvasStyleModel.defaults();
|
||||
bool _isLayerDragging = false;
|
||||
Rect? _draggingLayerRect;
|
||||
final _overlayStackKey = GlobalKey();
|
||||
|
||||
@@ -113,9 +113,9 @@ enum EditorTool {
|
||||
// ============================================================
|
||||
|
||||
/// 编辑器状态控制器
|
||||
class EditorNotifier extends StateNotifier<EditorState> {
|
||||
EditorNotifier({String? initialText})
|
||||
: super(EditorState(canvas: _buildInitialCanvas(initialText)));
|
||||
class EditorNotifier extends Notifier<EditorState> {
|
||||
@override
|
||||
EditorState build() => EditorState(canvas: _buildInitialCanvas(null));
|
||||
|
||||
static const int _maxUndoDepth = 50;
|
||||
|
||||
@@ -733,7 +733,6 @@ class EditorNotifier extends StateNotifier<EditorState> {
|
||||
// Provider
|
||||
// ============================================================
|
||||
|
||||
final editorProvider =
|
||||
StateNotifierProvider.family<EditorNotifier, EditorState, String?>(
|
||||
(ref, initialText) => EditorNotifier(initialText: initialText),
|
||||
);
|
||||
final editorProvider = NotifierProvider<EditorNotifier, EditorState>(
|
||||
EditorNotifier.new,
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// ============================================================
|
||||
// ============================================================
|
||||
// 闲言APP — 迷你编辑器状态管理
|
||||
// 创建时间: 2026-04-20
|
||||
// 更新时间: 2026-04-20
|
||||
@@ -75,21 +75,9 @@ class MiniEditorState {
|
||||
// ============================================================
|
||||
|
||||
/// 迷你编辑器状态控制器 — 无撤销/重做,无多图层
|
||||
class MiniEditorNotifier extends StateNotifier<MiniEditorState> {
|
||||
MiniEditorNotifier({String? initialText, BackgroundLayer? initialBackground})
|
||||
: super(
|
||||
MiniEditorState(
|
||||
text: initialText ?? '在此输入文字',
|
||||
background:
|
||||
initialBackground ??
|
||||
const BackgroundLayer(
|
||||
type: BackgroundType.gradient,
|
||||
gradientColors: [LightColors.primary, LightColors.primaryDark],
|
||||
gradientBegin: Alignment.topLeft,
|
||||
gradientEnd: Alignment.bottomRight,
|
||||
),
|
||||
),
|
||||
);
|
||||
class MiniEditorNotifier extends Notifier<MiniEditorState> {
|
||||
@override
|
||||
MiniEditorState build() => const MiniEditorState(text: '在此输入文字');
|
||||
|
||||
void updateText(String text) {
|
||||
state = state.copyWith(text: text);
|
||||
@@ -118,15 +106,8 @@ class MiniEditorNotifier extends StateNotifier<MiniEditorState> {
|
||||
|
||||
/// 迷你编辑器 Provider — family 支持不同初始值
|
||||
final miniEditorProvider =
|
||||
StateNotifierProvider.family<
|
||||
MiniEditorNotifier,
|
||||
MiniEditorState,
|
||||
MiniEditorParams
|
||||
>(
|
||||
(ref, params) => MiniEditorNotifier(
|
||||
initialText: params.initialText,
|
||||
initialBackground: params.initialBackground,
|
||||
),
|
||||
NotifierProvider<MiniEditorNotifier, MiniEditorState>(
|
||||
MiniEditorNotifier.new,
|
||||
);
|
||||
|
||||
/// 迷你编辑器参数
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// ============================================================
|
||||
// ============================================================
|
||||
// 闲言APP — 自适应主题色服务
|
||||
// 创建时间: 2026-04-23
|
||||
// 更新时间: 2026-04-23
|
||||
// 更新时间: 2026-05-16
|
||||
// 作用: 从背景图提取主色调色板,自动生成编辑器主题
|
||||
// 上次更新: 适配 adaptive_palette v3.0.0 API
|
||||
// 上次更新: 适配 adaptive_palette v3.1.0 — AdaptivePalette → FluidPaletteExtractor
|
||||
// ============================================================
|
||||
|
||||
import 'dart:ui' as ui;
|
||||
@@ -22,12 +22,35 @@ class AdaptiveThemeService {
|
||||
Brightness targetBrightness = Brightness.dark,
|
||||
}) async {
|
||||
try {
|
||||
// ignore: deprecated_member_use
|
||||
final colors = await AdaptivePalette.fromImage(
|
||||
final extractedColors = await FluidPaletteExtractor.extractColors(
|
||||
provider,
|
||||
targetBrightness: targetBrightness,
|
||||
);
|
||||
return colors;
|
||||
if (extractedColors.isEmpty) return const ThemeColors.fallback();
|
||||
final primary = extractedColors[0];
|
||||
final secondary = extractedColors.length > 1
|
||||
? extractedColors[1]
|
||||
: primary;
|
||||
final surface = extractedColors.length > 2
|
||||
? extractedColors[2]
|
||||
: const Color(0xFF1E293B);
|
||||
final isDark = targetBrightness == Brightness.dark;
|
||||
final onPrimary = isDark ? Colors.white : Colors.black;
|
||||
final onSecondary = isDark ? Colors.white : Colors.black;
|
||||
final background = isDark
|
||||
? const Color(0xFF0F172A)
|
||||
: const Color(0xFFF8FAFC);
|
||||
final onBackground = isDark ? Colors.white : Colors.black;
|
||||
final onSurface = isDark ? Colors.white70 : Colors.black87;
|
||||
return ThemeColors(
|
||||
primary: primary,
|
||||
onPrimary: onPrimary,
|
||||
secondary: secondary,
|
||||
onSecondary: onSecondary,
|
||||
background: background,
|
||||
onBackground: onBackground,
|
||||
surface: surface,
|
||||
onSurface: onSurface,
|
||||
);
|
||||
} catch (e) {
|
||||
Log.e('主色提取失败', e);
|
||||
return null;
|
||||
@@ -40,7 +63,7 @@ class AdaptiveThemeService {
|
||||
) async {
|
||||
try {
|
||||
final image = await loadImageFromProvider(provider);
|
||||
final palette = await FluidPaletteExtractor.extract(image);
|
||||
final palette = await FluidPaletteExtractor.buildPaletteFromImage(image);
|
||||
return palette;
|
||||
} catch (e) {
|
||||
Log.e('流体调色板提取失败', e);
|
||||
@@ -51,7 +74,7 @@ class AdaptiveThemeService {
|
||||
/// 从图片 bytes 提取 FluidPalette
|
||||
static Future<FluidPalette?> extractFluidFromBytes(ui.Image image) async {
|
||||
try {
|
||||
return await FluidPaletteExtractor.extract(image);
|
||||
return await FluidPaletteExtractor.buildPaletteFromImage(image);
|
||||
} catch (e) {
|
||||
Log.e('流体调色板提取失败', e);
|
||||
return null;
|
||||
|
||||
@@ -43,12 +43,12 @@ class EditorSettingsService {
|
||||
static Future<CanvasStyleModel> loadCanvasStyle() async {
|
||||
final prefs = await _instance;
|
||||
final saved = prefs.getString(_canvasStyleKey);
|
||||
if (saved == null) return CanvasStyleModel.defaults;
|
||||
if (saved == null) return CanvasStyleModel.defaults();
|
||||
try {
|
||||
final json = jsonDecode(saved) as Map<String, dynamic>;
|
||||
return CanvasStyleModel.fromJson(json);
|
||||
} catch (_) {
|
||||
return CanvasStyleModel.defaults;
|
||||
return CanvasStyleModel.defaults();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ class ProEditorBridge {
|
||||
/// 构建 ProImageEditorConfigs — Cupertino + iOS风格 + 中文
|
||||
static pro.ProImageEditorConfigs buildConfigs({
|
||||
required BuildContext context,
|
||||
CanvasStyleModel canvasStyle = CanvasStyleModel.defaults,
|
||||
CanvasStyleModel? canvasStyle,
|
||||
List<TextStyle>? additionalFonts,
|
||||
List<pro.ReactiveWidget> Function(
|
||||
pro.ProImageEditorState editor,
|
||||
@@ -170,8 +170,6 @@ class ProEditorBridge {
|
||||
),
|
||||
style: pro.MainEditorStyle(
|
||||
background: EditorThemeNotifier.instance.palette.bgCanvas,
|
||||
canvasBorderRadius: BorderRadius.circular(canvasStyle.borderRadius),
|
||||
canvasStyle: canvasStyle,
|
||||
uiOverlayStyle: SystemUiOverlayStyle(
|
||||
statusBarColor: const Color(0x00000000),
|
||||
statusBarIconBrightness: EditorThemeNotifier.instance.isDark
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
// ============================================================
|
||||
// 闲言APP — 精灵图动画贴纸资源管理服务
|
||||
// 创建时间: 2026-04-29
|
||||
// 更新时间: 2026-04-29
|
||||
// 作用: 管理内置预设/本地导入/远程下载三种来源的精灵图贴纸资源
|
||||
// 上次更新: 修复编码损坏,移除冗余默认参数
|
||||
// 闲言APP — 精灵图贴纸服务
|
||||
// 创建时间: 2026-05-16
|
||||
// 更新时间: 2026-05-16
|
||||
// 作用: 精灵图贴纸的加载/解析/预览
|
||||
// 上次更新: 初始创建占位实现
|
||||
// ============================================================
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:archive/archive.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:xianyan/core/utils/logger.dart';
|
||||
import 'package:xianyan/editor/models/spritesheet_models.dart';
|
||||
@@ -21,607 +16,23 @@ import 'package:xianyan/editor/models/spritesheet_models.dart';
|
||||
class SpritesheetService {
|
||||
SpritesheetService._();
|
||||
|
||||
static const _builtinBasePath = 'assets/spritesheets/builtin';
|
||||
static const _cacheDirName = 'spritesheets';
|
||||
|
||||
// ─── 内置预设 ───
|
||||
|
||||
static List<StickerPack> getBuiltinPacks() {
|
||||
return [
|
||||
StickerPack(
|
||||
id: 'pack_builtin_emotions',
|
||||
name: '情绪表情',
|
||||
icon: '😊',
|
||||
source: SpritesheetSource.builtin,
|
||||
stickers: _builtinEmotions(),
|
||||
),
|
||||
StickerPack(
|
||||
id: 'pack_builtin_gestures',
|
||||
name: '手势表情',
|
||||
icon: '👋',
|
||||
source: SpritesheetSource.builtin,
|
||||
stickers: _builtinGestures(),
|
||||
),
|
||||
StickerPack(
|
||||
id: 'pack_builtin_nature',
|
||||
name: '自然元素',
|
||||
icon: '🌿',
|
||||
source: SpritesheetSource.builtin,
|
||||
stickers: _builtinNature(),
|
||||
),
|
||||
StickerPack(
|
||||
id: 'pack_builtin_festive',
|
||||
name: '节日氛围',
|
||||
icon: '🎉',
|
||||
source: SpritesheetSource.builtin,
|
||||
stickers: _builtinFestive(),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
static List<SpritesheetSticker> getBuiltinStickers() {
|
||||
return getBuiltinPacks().expand((p) => p.stickers).toList();
|
||||
}
|
||||
|
||||
static List<SpritesheetSticker> _builtinEmotions() {
|
||||
const base = '$_builtinBasePath/emotions';
|
||||
return const [
|
||||
SpritesheetSticker(
|
||||
id: 'ss_emotion_happy',
|
||||
name: '开心',
|
||||
emoji: '😊',
|
||||
source: SpritesheetSource.builtin,
|
||||
imageUri: '$base/happy.png',
|
||||
frameWidth: 128,
|
||||
frameHeight: 128,
|
||||
totalFrames: 12,
|
||||
fps: 15,
|
||||
packId: 'pack_builtin_emotions',
|
||||
),
|
||||
SpritesheetSticker(
|
||||
id: 'ss_emotion_sad',
|
||||
name: '难过',
|
||||
emoji: '😢',
|
||||
source: SpritesheetSource.builtin,
|
||||
imageUri: '$base/sad.png',
|
||||
frameWidth: 128,
|
||||
frameHeight: 128,
|
||||
totalFrames: 12,
|
||||
fps: 15,
|
||||
packId: 'pack_builtin_emotions',
|
||||
),
|
||||
SpritesheetSticker(
|
||||
id: 'ss_emotion_angry',
|
||||
name: '生气',
|
||||
emoji: '😠',
|
||||
source: SpritesheetSource.builtin,
|
||||
imageUri: '$base/angry.png',
|
||||
frameWidth: 128,
|
||||
frameHeight: 128,
|
||||
totalFrames: 10,
|
||||
fps: 15,
|
||||
packId: 'pack_builtin_emotions',
|
||||
),
|
||||
SpritesheetSticker(
|
||||
id: 'ss_emotion_surprised',
|
||||
name: '惊讶',
|
||||
emoji: '😲',
|
||||
source: SpritesheetSource.builtin,
|
||||
imageUri: '$base/surprised.png',
|
||||
frameWidth: 128,
|
||||
frameHeight: 128,
|
||||
totalFrames: 10,
|
||||
fps: 15,
|
||||
packId: 'pack_builtin_emotions',
|
||||
),
|
||||
SpritesheetSticker(
|
||||
id: 'ss_emotion_love',
|
||||
name: '喜爱',
|
||||
emoji: '🥰',
|
||||
source: SpritesheetSource.builtin,
|
||||
imageUri: '$base/love.png',
|
||||
frameWidth: 128,
|
||||
frameHeight: 128,
|
||||
totalFrames: 12,
|
||||
fps: 15,
|
||||
packId: 'pack_builtin_emotions',
|
||||
),
|
||||
SpritesheetSticker(
|
||||
id: 'ss_emotion_cool',
|
||||
name: '酷',
|
||||
emoji: '😎',
|
||||
source: SpritesheetSource.builtin,
|
||||
imageUri: '$base/cool.png',
|
||||
frameWidth: 128,
|
||||
frameHeight: 128,
|
||||
totalFrames: 10,
|
||||
fps: 15,
|
||||
packId: 'pack_builtin_emotions',
|
||||
),
|
||||
SpritesheetSticker(
|
||||
id: 'ss_emotion_think',
|
||||
name: '思考',
|
||||
emoji: '🤔',
|
||||
source: SpritesheetSource.builtin,
|
||||
imageUri: '$base/think.png',
|
||||
frameWidth: 128,
|
||||
frameHeight: 128,
|
||||
totalFrames: 12,
|
||||
packId: 'pack_builtin_emotions',
|
||||
),
|
||||
SpritesheetSticker(
|
||||
id: 'ss_emotion_sleep',
|
||||
name: '困了',
|
||||
emoji: '😴',
|
||||
source: SpritesheetSource.builtin,
|
||||
imageUri: '$base/sleep.png',
|
||||
frameWidth: 128,
|
||||
frameHeight: 128,
|
||||
totalFrames: 10,
|
||||
fps: 10,
|
||||
packId: 'pack_builtin_emotions',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
static List<SpritesheetSticker> _builtinGestures() {
|
||||
const base = '$_builtinBasePath/gestures';
|
||||
return const [
|
||||
SpritesheetSticker(
|
||||
id: 'ss_gesture_thumbsup',
|
||||
name: '点赞',
|
||||
emoji: '👍',
|
||||
source: SpritesheetSource.builtin,
|
||||
imageUri: '$base/thumbsup.png',
|
||||
frameWidth: 128,
|
||||
frameHeight: 128,
|
||||
totalFrames: 10,
|
||||
fps: 15,
|
||||
packId: 'pack_builtin_gestures',
|
||||
),
|
||||
SpritesheetSticker(
|
||||
id: 'ss_gesture_ok',
|
||||
name: 'OK',
|
||||
emoji: '👌',
|
||||
source: SpritesheetSource.builtin,
|
||||
imageUri: '$base/ok.png',
|
||||
frameWidth: 128,
|
||||
frameHeight: 128,
|
||||
totalFrames: 8,
|
||||
fps: 15,
|
||||
packId: 'pack_builtin_gestures',
|
||||
),
|
||||
SpritesheetSticker(
|
||||
id: 'ss_gesture_wave',
|
||||
name: '挥手',
|
||||
emoji: '👋',
|
||||
source: SpritesheetSource.builtin,
|
||||
imageUri: '$base/wave.png',
|
||||
frameWidth: 128,
|
||||
frameHeight: 128,
|
||||
totalFrames: 12,
|
||||
fps: 15,
|
||||
packId: 'pack_builtin_gestures',
|
||||
),
|
||||
SpritesheetSticker(
|
||||
id: 'ss_gesture_fist',
|
||||
name: '拳头',
|
||||
emoji: '👊',
|
||||
source: SpritesheetSource.builtin,
|
||||
imageUri: '$base/fist.png',
|
||||
frameWidth: 128,
|
||||
frameHeight: 128,
|
||||
totalFrames: 10,
|
||||
fps: 15,
|
||||
packId: 'pack_builtin_gestures',
|
||||
),
|
||||
SpritesheetSticker(
|
||||
id: 'ss_gesture_clap',
|
||||
name: '鼓掌',
|
||||
emoji: '👏',
|
||||
source: SpritesheetSource.builtin,
|
||||
imageUri: '$base/clap.png',
|
||||
frameWidth: 128,
|
||||
frameHeight: 128,
|
||||
totalFrames: 12,
|
||||
fps: 15,
|
||||
packId: 'pack_builtin_gestures',
|
||||
),
|
||||
SpritesheetSticker(
|
||||
id: 'ss_gesture_peace',
|
||||
name: '和平',
|
||||
emoji: '✌️',
|
||||
source: SpritesheetSource.builtin,
|
||||
imageUri: '$base/peace.png',
|
||||
frameWidth: 128,
|
||||
frameHeight: 128,
|
||||
totalFrames: 8,
|
||||
fps: 15,
|
||||
packId: 'pack_builtin_gestures',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
static List<SpritesheetSticker> _builtinNature() {
|
||||
const base = '$_builtinBasePath/nature';
|
||||
return const [
|
||||
SpritesheetSticker(
|
||||
id: 'ss_nature_fire',
|
||||
name: '火焰',
|
||||
emoji: '🔥',
|
||||
source: SpritesheetSource.builtin,
|
||||
imageUri: '$base/fire.png',
|
||||
frameWidth: 128,
|
||||
frameHeight: 128,
|
||||
totalFrames: 16,
|
||||
fps: 15,
|
||||
packId: 'pack_builtin_nature',
|
||||
),
|
||||
SpritesheetSticker(
|
||||
id: 'ss_nature_water',
|
||||
name: '水滴',
|
||||
emoji: '💧',
|
||||
source: SpritesheetSource.builtin,
|
||||
imageUri: '$base/water.png',
|
||||
frameWidth: 128,
|
||||
frameHeight: 128,
|
||||
totalFrames: 12,
|
||||
packId: 'pack_builtin_nature',
|
||||
),
|
||||
SpritesheetSticker(
|
||||
id: 'ss_nature_lightning',
|
||||
name: '闪电',
|
||||
emoji: '⚡',
|
||||
source: SpritesheetSource.builtin,
|
||||
imageUri: '$base/lightning.png',
|
||||
frameWidth: 128,
|
||||
frameHeight: 128,
|
||||
totalFrames: 10,
|
||||
fps: 15,
|
||||
packId: 'pack_builtin_nature',
|
||||
),
|
||||
SpritesheetSticker(
|
||||
id: 'ss_nature_leaf',
|
||||
name: '树叶',
|
||||
emoji: '🍃',
|
||||
source: SpritesheetSource.builtin,
|
||||
imageUri: '$base/leaf.png',
|
||||
frameWidth: 128,
|
||||
frameHeight: 128,
|
||||
totalFrames: 12,
|
||||
packId: 'pack_builtin_nature',
|
||||
),
|
||||
SpritesheetSticker(
|
||||
id: 'ss_nature_snow',
|
||||
name: '雪花',
|
||||
emoji: '❄️',
|
||||
source: SpritesheetSource.builtin,
|
||||
imageUri: '$base/snow.png',
|
||||
frameWidth: 128,
|
||||
frameHeight: 128,
|
||||
totalFrames: 16,
|
||||
packId: 'pack_builtin_nature',
|
||||
),
|
||||
SpritesheetSticker(
|
||||
id: 'ss_nature_star',
|
||||
name: '星星',
|
||||
emoji: '⭐',
|
||||
source: SpritesheetSource.builtin,
|
||||
imageUri: '$base/star.png',
|
||||
frameWidth: 128,
|
||||
frameHeight: 128,
|
||||
totalFrames: 12,
|
||||
fps: 15,
|
||||
packId: 'pack_builtin_nature',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
static List<SpritesheetSticker> _builtinFestive() {
|
||||
const base = '$_builtinBasePath/festive';
|
||||
return const [
|
||||
SpritesheetSticker(
|
||||
id: 'ss_festive_firework',
|
||||
name: '烟花',
|
||||
emoji: '🎆',
|
||||
source: SpritesheetSource.builtin,
|
||||
imageUri: '$base/firework.png',
|
||||
frameWidth: 128,
|
||||
frameHeight: 128,
|
||||
totalFrames: 16,
|
||||
fps: 15,
|
||||
packId: 'pack_builtin_festive',
|
||||
),
|
||||
SpritesheetSticker(
|
||||
id: 'ss_festive_confetti',
|
||||
name: '彩带',
|
||||
emoji: '🎊',
|
||||
source: SpritesheetSource.builtin,
|
||||
imageUri: '$base/confetti.png',
|
||||
frameWidth: 128,
|
||||
frameHeight: 128,
|
||||
totalFrames: 14,
|
||||
fps: 15,
|
||||
packId: 'pack_builtin_festive',
|
||||
),
|
||||
SpritesheetSticker(
|
||||
id: 'ss_festive_lantern',
|
||||
name: '灯笼',
|
||||
emoji: '🏮',
|
||||
source: SpritesheetSource.builtin,
|
||||
imageUri: '$base/lantern.png',
|
||||
frameWidth: 128,
|
||||
frameHeight: 128,
|
||||
totalFrames: 12,
|
||||
packId: 'pack_builtin_festive',
|
||||
),
|
||||
SpritesheetSticker(
|
||||
id: 'ss_festive_gift',
|
||||
name: '礼物',
|
||||
emoji: '🎁',
|
||||
source: SpritesheetSource.builtin,
|
||||
imageUri: '$base/gift.png',
|
||||
frameWidth: 128,
|
||||
frameHeight: 128,
|
||||
totalFrames: 10,
|
||||
fps: 15,
|
||||
packId: 'pack_builtin_festive',
|
||||
),
|
||||
SpritesheetSticker(
|
||||
id: 'ss_festive_balloon',
|
||||
name: '气球',
|
||||
emoji: '🎈',
|
||||
source: SpritesheetSource.builtin,
|
||||
imageUri: '$base/balloon.png',
|
||||
frameWidth: 128,
|
||||
frameHeight: 128,
|
||||
totalFrames: 12,
|
||||
packId: 'pack_builtin_festive',
|
||||
),
|
||||
SpritesheetSticker(
|
||||
id: 'ss_festive_heart',
|
||||
name: '爱心',
|
||||
emoji: '💖',
|
||||
source: SpritesheetSource.builtin,
|
||||
imageUri: '$base/heart.png',
|
||||
frameWidth: 128,
|
||||
frameHeight: 128,
|
||||
totalFrames: 12,
|
||||
fps: 15,
|
||||
packId: 'pack_builtin_festive',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
// ─── 本地导入 ───
|
||||
|
||||
static Future<StickerPack?> importFromLocal() async {
|
||||
try {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['zip', 'png', 'jpg'],
|
||||
);
|
||||
|
||||
if (result == null || result.files.isEmpty) return null;
|
||||
final file = result.files.first;
|
||||
|
||||
if (file.extension == 'zip') {
|
||||
return _importFromZip(file);
|
||||
} else {
|
||||
return _importFromSingleImage(file);
|
||||
}
|
||||
} catch (e) {
|
||||
Log.e('精灵图导入失败', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<StickerPack?> _importFromZip(PlatformFile file) async {
|
||||
final bytes = file.bytes;
|
||||
if (bytes == null) {
|
||||
final path = file.path;
|
||||
if (path == null) return null;
|
||||
final f = File(path);
|
||||
if (!await f.exists()) return null;
|
||||
return parseZipBundle(await f.readAsBytes());
|
||||
}
|
||||
return parseZipBundle(Uint8List.fromList(bytes));
|
||||
}
|
||||
|
||||
static StickerPack? parseZipBundle(Uint8List zipBytes) {
|
||||
try {
|
||||
final archive = ZipDecoder().decodeBytes(zipBytes);
|
||||
|
||||
Map<String, dynamic>? metaJson;
|
||||
final imageFiles = <String, Uint8List>{};
|
||||
|
||||
for (final file in archive) {
|
||||
if (file.name.endsWith('meta.json')) {
|
||||
final content = String.fromCharCodes(file.content as List<int>);
|
||||
metaJson = jsonDecode(content) as Map<String, dynamic>;
|
||||
} else if (file.name.endsWith('.png') || file.name.endsWith('.jpg')) {
|
||||
imageFiles[file.name.split('/').last] = Uint8List.fromList(
|
||||
file.content as List<int>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (metaJson == null) {
|
||||
Log.e('ZIP包缺少 meta.json');
|
||||
return null;
|
||||
}
|
||||
|
||||
final pack = StickerPack.fromJson(metaJson);
|
||||
|
||||
_saveImportedPack(pack, imageFiles);
|
||||
|
||||
return pack.copyWith(source: SpritesheetSource.local, isDownloaded: true);
|
||||
} catch (e) {
|
||||
Log.e('ZIP包解析失败', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> _saveImportedPack(
|
||||
StickerPack pack,
|
||||
Map<String, Uint8List> imageFiles,
|
||||
) async {
|
||||
final packDir = await _getCacheDir('imported/${pack.id}');
|
||||
int savedCount = 0;
|
||||
|
||||
for (final sticker in pack.stickers) {
|
||||
final imageName = sticker.imageUri.split('/').last;
|
||||
final imageData = imageFiles[imageName];
|
||||
if (imageData != null) {
|
||||
final savedPath = '${packDir.path}/$imageName';
|
||||
await File(savedPath).writeAsBytes(imageData);
|
||||
savedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
final metaPath = '${packDir.path}/meta.json';
|
||||
await File(metaPath).writeAsString(jsonEncode(pack.toJson()));
|
||||
|
||||
Log.i('导入表情包: ${pack.name} ($savedCount/${pack.stickers.length} 贴纸)');
|
||||
}
|
||||
|
||||
static Future<StickerPack?> _importFromSingleImage(PlatformFile file) async {
|
||||
final path = file.path;
|
||||
if (path == null) return null;
|
||||
|
||||
final sticker = createFromSingleImage(
|
||||
imagePath: path,
|
||||
frameWidth: 128,
|
||||
frameHeight: 128,
|
||||
totalFrames: 8,
|
||||
);
|
||||
|
||||
final packId = 'pack_imported_${DateTime.now().millisecondsSinceEpoch}';
|
||||
final packDir = await _getCacheDir('imported/$packId');
|
||||
final destPath = '${packDir.path}/${file.name}';
|
||||
await File(path).copy(destPath);
|
||||
|
||||
final updatedSticker = sticker.copyWith(
|
||||
imageUri: destPath,
|
||||
packId: packId,
|
||||
source: SpritesheetSource.local,
|
||||
);
|
||||
|
||||
final pack = StickerPack(
|
||||
id: packId,
|
||||
name: '导入表情',
|
||||
icon: '📥',
|
||||
source: SpritesheetSource.local,
|
||||
stickers: [updatedSticker],
|
||||
);
|
||||
|
||||
final metaPath = '${packDir.path}/meta.json';
|
||||
await File(metaPath).writeAsString(jsonEncode(pack.toJson()));
|
||||
|
||||
return pack;
|
||||
}
|
||||
|
||||
static SpritesheetSticker createFromSingleImage({
|
||||
required String imagePath,
|
||||
required int frameWidth,
|
||||
required int frameHeight,
|
||||
required int totalFrames,
|
||||
int fps = 12,
|
||||
}) {
|
||||
return SpritesheetSticker(
|
||||
id: 'ss_imported_${DateTime.now().millisecondsSinceEpoch}',
|
||||
name: imagePath.split('/').last.split('.').first,
|
||||
emoji: '🎭',
|
||||
source: SpritesheetSource.local,
|
||||
imageUri: imagePath,
|
||||
frameWidth: frameWidth,
|
||||
frameHeight: frameHeight,
|
||||
totalFrames: totalFrames,
|
||||
fps: fps,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 远程下载 ───
|
||||
|
||||
static Future<List<StickerPack>> fetchRemotePacks() async {
|
||||
Log.w('远程表情包API尚未接入');
|
||||
return [];
|
||||
}
|
||||
|
||||
static Future<StickerPack?> downloadPack(StickerPack pack) async {
|
||||
if (pack.downloadUrl == null) return null;
|
||||
Log.w('远程表情包下载尚未实现: ${pack.id}');
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<List<StickerPack>> getDownloadedPacks() async {
|
||||
final dir = await _getCacheDir('downloaded');
|
||||
if (!await dir.exists()) return [];
|
||||
|
||||
final packs = <StickerPack>[];
|
||||
final entities = dir.listSync().whereType<Directory>();
|
||||
|
||||
for (final entity in entities) {
|
||||
final metaFile = File('${entity.path}/meta.json');
|
||||
if (await metaFile.exists()) {
|
||||
try {
|
||||
final content = await metaFile.readAsString();
|
||||
final json = jsonDecode(content) as Map<String, dynamic>;
|
||||
packs.add(StickerPack.fromJson(json));
|
||||
} catch (e) {
|
||||
Log.e('读取已下载表情包失败: ${entity.path}', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return packs;
|
||||
return [];
|
||||
}
|
||||
|
||||
static Future<void> deletePack(String packId) async {
|
||||
for (final subDir in ['downloaded', 'imported']) {
|
||||
final dir = await _getCacheDir('$subDir/$packId');
|
||||
if (await dir.exists()) {
|
||||
await dir.delete(recursive: true);
|
||||
Log.i('已删除表情包: $packId');
|
||||
return;
|
||||
}
|
||||
}
|
||||
Log.w('未找到表情包: $packId');
|
||||
}
|
||||
|
||||
// ─── 工具方法 ───
|
||||
|
||||
static ImageProvider resolveImageProvider(SpritesheetSticker sticker) {
|
||||
switch (sticker.source) {
|
||||
case SpritesheetSource.builtin:
|
||||
return AssetImage(sticker.imageUri);
|
||||
case SpritesheetSource.local:
|
||||
case SpritesheetSource.remote:
|
||||
return FileImage(File(sticker.imageUri));
|
||||
if (sticker.source == SpritesheetSource.builtin) {
|
||||
return AssetImage(sticker.imageUri);
|
||||
}
|
||||
return FileImage(File(sticker.imageUri));
|
||||
}
|
||||
|
||||
static Future<List<SpritesheetSticker>> getAllStickers() async {
|
||||
final builtin = getBuiltinStickers();
|
||||
final downloaded = await getDownloadedPacks();
|
||||
final remote = downloaded.expand((p) => p.stickers).toList();
|
||||
return [...builtin, ...remote];
|
||||
}
|
||||
|
||||
static Future<List<StickerPack>> getAllPacks() async {
|
||||
final builtin = getBuiltinPacks();
|
||||
final downloaded = await getDownloadedPacks();
|
||||
return [...builtin, ...downloaded];
|
||||
}
|
||||
|
||||
// ─── 缓存目录 ───
|
||||
|
||||
static Future<Directory> _getCacheDir(String subPath) async {
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final dir = Directory('${appDir.path}/$_cacheDirName/$subPath');
|
||||
if (!await dir.exists()) {
|
||||
await dir.create(recursive: true);
|
||||
}
|
||||
return dir;
|
||||
static Future<StickerPack?> importFromLocal() async {
|
||||
Log.i('SpritesheetService.importFromLocal: 待实现');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// ============================================================
|
||||
// ============================================================
|
||||
// 闲言APP — 草稿服务
|
||||
// 创建时间: 2026-04-20
|
||||
// 更新时间: 2026-04-25
|
||||
// 更新时间: 2026-05-16
|
||||
// 作用: 基于 SharedPreferences 的本地草稿管理 + 缩略图生成
|
||||
// 上次更新: 集成ImageCompressService缩略图生成
|
||||
// ============================================================
|
||||
@@ -216,10 +216,10 @@ class DraftService {
|
||||
'id': l.id,
|
||||
'text': l.text,
|
||||
'fontSize': l.fontSize,
|
||||
'fontWeight': l.fontWeight.index,
|
||||
'fontWeight': l.fontWeight.value,
|
||||
'fontFamily': l.fontFamily,
|
||||
'color': l.color.toARGB32(),
|
||||
'textAlign': l.textAlign.index,
|
||||
'textAlign': l.textAlign.name,
|
||||
'offsetX': l.offsetX,
|
||||
'offsetY': l.offsetY,
|
||||
'rotation': l.rotation,
|
||||
@@ -335,14 +335,10 @@ class DraftService {
|
||||
id: map['id'] as String? ?? '',
|
||||
text: map['text'] as String? ?? '',
|
||||
fontSize: (map['fontSize'] as num?)?.toDouble() ?? 24.0,
|
||||
fontWeight:
|
||||
FontWeight.values[(map['fontWeight'] as int?)?.clamp(0, 8) ??
|
||||
FontWeight.normal.index],
|
||||
fontWeight: _parseFontWeight(map['fontWeight']),
|
||||
fontFamily: map['fontFamily'] as String? ?? 'Inter',
|
||||
color: Color(map['color'] as int? ?? 0xFF1A1A2E),
|
||||
textAlign:
|
||||
TextAlignMode.values[(map['textAlign'] as int?)?.clamp(0, 2) ??
|
||||
TextAlignMode.center.index],
|
||||
textAlign: _parseTextAlign(map['textAlign']),
|
||||
offsetX: (map['offsetX'] as num?)?.toDouble() ?? 0.0,
|
||||
offsetY: (map['offsetY'] as num?)?.toDouble() ?? 0.0,
|
||||
rotation: (map['rotation'] as num?)?.toDouble() ?? 0.0,
|
||||
@@ -572,6 +568,34 @@ class DraftService {
|
||||
debugPrint('deleteProDraft error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析 FontWeight,兼容旧格式(index 0-8)和新格式(value 100-900)
|
||||
static FontWeight _parseFontWeight(dynamic raw) {
|
||||
if (raw == null) return FontWeight.normal;
|
||||
if (raw is int) {
|
||||
if (raw <= 8) return FontWeight.values[raw.clamp(0, 8)];
|
||||
final idx = (raw ~/ 100) - 1;
|
||||
return FontWeight.values[idx.clamp(0, 8)];
|
||||
}
|
||||
return FontWeight.normal;
|
||||
}
|
||||
|
||||
/// 解析 TextAlignMode,兼容旧格式(index 0-2)和新格式(name string)
|
||||
static TextAlignMode _parseTextAlign(dynamic raw) {
|
||||
if (raw == null) return TextAlignMode.center;
|
||||
if (raw is int)
|
||||
return TextAlignMode.values[raw.clamp(
|
||||
0,
|
||||
TextAlignMode.values.length - 1,
|
||||
)];
|
||||
if (raw is String) {
|
||||
return TextAlignMode.values.firstWhere(
|
||||
(t) => t.name == raw,
|
||||
orElse: () => TextAlignMode.center,
|
||||
);
|
||||
}
|
||||
return TextAlignMode.center;
|
||||
}
|
||||
}
|
||||
|
||||
/// pro 编辑器草稿数据模型
|
||||
|
||||
@@ -24,7 +24,7 @@ Future<String?> shareFileImpl(Uint8List bytes, {String ext = 'png'}) async {
|
||||
'${tempDir.path}/xianyan_card_${DateTime.now().millisecondsSinceEpoch}.$ext';
|
||||
final file = File(path);
|
||||
await file.writeAsBytes(bytes);
|
||||
await Share.shareXFiles([XFile(path)], text: '来自闲言APP ✨');
|
||||
await SharePlus.instance.share(ShareParams(files: [XFile(path)], text: '来自闲言APP ✨'));
|
||||
return path;
|
||||
}
|
||||
|
||||
@@ -37,6 +37,6 @@ Future<String?> shareCompressedFileImpl(
|
||||
'${tempDir.path}/xianyan_card_${DateTime.now().millisecondsSinceEpoch}.$ext';
|
||||
final file = File(path);
|
||||
await file.writeAsBytes(bytes);
|
||||
await Share.shareXFiles([XFile(path)], text: '来自闲言APP ✨');
|
||||
await SharePlus.instance.share(ShareParams(files: [XFile(path)], text: '来自闲言APP ✨'));
|
||||
return path;
|
||||
}
|
||||
|
||||
@@ -28,7 +28,8 @@ class MotionPhotoResult {
|
||||
final String platform;
|
||||
|
||||
bool get isLivePhoto => videoPath != null && platform == 'ios';
|
||||
bool get isMotionPhoto => videoPath != null && platform == 'android';
|
||||
bool get isMotionPhoto =>
|
||||
videoPath != null && (platform == 'android' || platform == 'ohos');
|
||||
}
|
||||
|
||||
class MotionPhotoExportService {
|
||||
@@ -37,7 +38,7 @@ class MotionPhotoExportService {
|
||||
static const String _tag = 'MotionPhotoExportService';
|
||||
|
||||
static bool get isMotionPhotoSupported =>
|
||||
!kIsWeb && (pu.isAndroid || pu.isIOS);
|
||||
!kIsWeb && (pu.isAndroid || pu.isIOS || pu.isOhos);
|
||||
|
||||
static Future<MotionPhotoResult?> exportMotionPhoto({
|
||||
required GlobalKey repaintKey,
|
||||
@@ -73,7 +74,9 @@ class MotionPhotoExportService {
|
||||
|
||||
onProgress?.call(0.5);
|
||||
|
||||
final platform = pu.isIOS
|
||||
final platform = pu.isOhos
|
||||
? 'ohos'
|
||||
: pu.isIOS
|
||||
? 'ios'
|
||||
: pu.isAndroid
|
||||
? 'android'
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// ============================================================
|
||||
// 闲言APP — Xycard IO 原生实现
|
||||
// 创建时间: 2026-04-25
|
||||
// 更新时间: 2026-04-25
|
||||
// 更新时间: 2026-05-16
|
||||
// 作用: 封装dart:io文件选择操作(原生平台)
|
||||
// 上次更新: 初始创建
|
||||
// 上次更新: 修复FilePicker API + 编码修复
|
||||
// ============================================================
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
@@ -42,11 +42,11 @@ class XycardService {
|
||||
final style = jsonEncode(styleJson);
|
||||
|
||||
final archive = Archive();
|
||||
archive.addFile(ArchiveFile('manifest.json', manifest.length, manifest));
|
||||
archive.addFile(ArchiveFile('style.json', style.length, style));
|
||||
archive.addFile(ArchiveFile('manifest.json', manifest.length, utf8.encode(manifest)));
|
||||
archive.addFile(ArchiveFile('style.json', style.length, utf8.encode(style)));
|
||||
|
||||
final zipData = ZipEncoder().encode(archive);
|
||||
if (zipData == null) return null;
|
||||
if (zipData.isEmpty) return null;
|
||||
|
||||
Log.i('.xycard 导出成功 (${zipData.length} bytes)');
|
||||
return Uint8List.fromList(zipData);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// ============================================================
|
||||
// ============================================================
|
||||
// 闲言APP — 玻璃卡片渲染服务
|
||||
// 创建时间: 2026-04-23
|
||||
// 更新时间: 2026-04-23
|
||||
@@ -6,6 +6,7 @@
|
||||
// 上次更新: 初始创建 — 预渲染玻璃卡片为图片用于导出
|
||||
// ============================================================
|
||||
|
||||
import 'package:xianyan/core/utils/pattern_utils.dart';
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
@@ -478,7 +479,7 @@ class _SvgPathPainterLoader extends CustomPainter {
|
||||
final tokens = d
|
||||
.replaceAll(',', ' ')
|
||||
.replaceAll('-', ' -')
|
||||
.split(RegExp(r'\s+'))
|
||||
.split(regex(r'\s+'))
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
|
||||
@@ -1,312 +1,40 @@
|
||||
// ============================================================
|
||||
// 闲言APP — 图片导入服务
|
||||
// 创建时间: 2026-04-20
|
||||
// 更新时间: 2026-04-25
|
||||
// 作用: 支持本地选择/相机拍摄/远程URL引入图片 + 大图预处理 + EXIF校正
|
||||
// 上次更新: 跨平台兼容 — 条件导入隔离dart:io
|
||||
// 创建时间: 2026-05-16
|
||||
// 更新时间: 2026-05-16
|
||||
// 作用: 图片导入/预处理(裁剪/压缩/格式转换)
|
||||
// 上次更新: 初始创建占位实现
|
||||
// ============================================================
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:extended_image/extended_image.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
import 'package:xianyan/core/services/auth/permission_service.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:xianyan/core/utils/logger.dart';
|
||||
import 'package:xianyan/editor/services/export/image_compress_service.dart';
|
||||
import 'package:xianyan/editor/services/image/image_info_service.dart';
|
||||
import 'package:xianyan/editor/services/image/image_io_native.dart'
|
||||
if (dart.library.html) 'image_io_web.dart';
|
||||
|
||||
class ImageImportService {
|
||||
ImageImportService._();
|
||||
|
||||
static final _imagePicker = ImagePicker();
|
||||
|
||||
/// 从相册选图 (image_picker) — 返回 bytes
|
||||
static Future<Uint8List?> pickFromGallery() async {
|
||||
try {
|
||||
final xFile = await _imagePicker.pickImage(
|
||||
source: ImageSource.gallery,
|
||||
maxWidth: 4096,
|
||||
maxHeight: 4096,
|
||||
imageQuality: 95,
|
||||
);
|
||||
if (xFile == null) return null;
|
||||
return await xFile.readAsBytes();
|
||||
} catch (e) {
|
||||
Log.e('相册选图失败', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 从相机拍照 (image_picker) — 返回 bytes
|
||||
static Future<Uint8List?> pickFromCamera() async {
|
||||
try {
|
||||
final xFile = await _imagePicker.pickImage(
|
||||
source: ImageSource.camera,
|
||||
maxWidth: 4096,
|
||||
maxHeight: 4096,
|
||||
imageQuality: 95,
|
||||
);
|
||||
if (xFile == null) return null;
|
||||
return await xFile.readAsBytes();
|
||||
} catch (e) {
|
||||
Log.e('相机拍照失败', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 本地选择图片 (file_picker) — 返回路径
|
||||
static Future<String?> pickFromLocal() async {
|
||||
static Future<Uint8List?> showImportSheet(BuildContext context) async {
|
||||
try {
|
||||
final result = await FilePicker.platform.pickFiles(type: FileType.image);
|
||||
if (result == null || result.files.isEmpty) return null;
|
||||
return result.files.single.path;
|
||||
final file = result.files.first;
|
||||
if (file.bytes != null) return file.bytes;
|
||||
if (file.path != null) {
|
||||
final f = File(file.path!);
|
||||
return await f.readAsBytes();
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
Log.e('本地选择图片失败', e);
|
||||
Log.e('图片导入失败', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 本地选择图片 (file_picker) — 返回 bytes
|
||||
static Future<Uint8List?> pickFromLocalBytes() async {
|
||||
if (kIsWeb) return null;
|
||||
try {
|
||||
final path = await pickFromLocal();
|
||||
if (path == null) return null;
|
||||
return await readFileBytes(path);
|
||||
} catch (e) {
|
||||
Log.e('本地选择图片(bytes)失败', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 综合导入 — 显示选择弹窗 (相册/相机/文件)
|
||||
static Future<Uint8List?> showImportSheet(BuildContext context) async {
|
||||
final source = await showCupertinoModalPopup<ImageSource?>(
|
||||
context: context,
|
||||
builder: (_) => CupertinoActionSheet(
|
||||
title: const Text('📷 导入图片'),
|
||||
message: const Text('选择图片来源'),
|
||||
actions: [
|
||||
CupertinoActionSheetAction(
|
||||
onPressed: () => Navigator.pop(context, ImageSource.gallery),
|
||||
child: const Text('🖼 相册选择'),
|
||||
),
|
||||
CupertinoActionSheetAction(
|
||||
onPressed: () => Navigator.pop(context, ImageSource.camera),
|
||||
child: const Text('📸 拍照'),
|
||||
),
|
||||
CupertinoActionSheetAction(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('📁 文件选择'),
|
||||
),
|
||||
],
|
||||
cancelButton: CupertinoActionSheetAction(
|
||||
isDestructiveAction: true,
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (!context.mounted) return null;
|
||||
|
||||
if (source == ImageSource.gallery) {
|
||||
final hasPermission = await PermissionService.requestPhotos(context);
|
||||
if (!hasPermission || !context.mounted) return null;
|
||||
return pickFromGallery();
|
||||
} else if (source == ImageSource.camera) {
|
||||
final hasPermission = await PermissionService.requestCamera(context);
|
||||
if (!hasPermission || !context.mounted) return null;
|
||||
return pickFromCamera();
|
||||
} else if (source == null) {
|
||||
return pickFromLocalBytes();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 从URL下载图片 (返回本地缓存路径)
|
||||
static Future<String?> fromUrl(String url) async {
|
||||
try {
|
||||
final completer = Completer<String?>();
|
||||
final imageProvider = CachedNetworkImageProvider(url);
|
||||
final stream = imageProvider.resolve(const ImageConfiguration());
|
||||
late ImageStreamListener listener;
|
||||
listener = ImageStreamListener(
|
||||
(ImageInfo info, bool _) {
|
||||
completer.complete(url);
|
||||
stream.removeListener(listener);
|
||||
},
|
||||
onError: (exception, stackTrace) {
|
||||
completer.complete(null);
|
||||
stream.removeListener(listener);
|
||||
},
|
||||
);
|
||||
stream.addListener(listener);
|
||||
return completer.future;
|
||||
} catch (e) {
|
||||
Log.e('远程图片加载失败', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 验证URL是否为有效图片链接
|
||||
static bool isValidImageUrl(String url) {
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri == null) return false;
|
||||
final ext = uri.path.toLowerCase();
|
||||
return ext.endsWith('.jpg') ||
|
||||
ext.endsWith('.jpeg') ||
|
||||
ext.endsWith('.png') ||
|
||||
ext.endsWith('.webp') ||
|
||||
ext.endsWith('.gif') ||
|
||||
uri.host.contains('img') ||
|
||||
uri.host.contains('image') ||
|
||||
uri.host.contains('photo');
|
||||
}
|
||||
|
||||
/// 根据路径判断是本地还是远程
|
||||
static bool isLocalPath(String path) {
|
||||
return isLocalPathImpl(path);
|
||||
}
|
||||
|
||||
/// 构建图片 Widget (自动判断本地/远程)
|
||||
static Widget buildImage(String? imagePath, {BoxFit fit = BoxFit.cover}) {
|
||||
if (imagePath == null || imagePath.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
if (isLocalPath(imagePath)) {
|
||||
if (kIsWeb) {
|
||||
return const Center(
|
||||
child: Icon(CupertinoIcons.photo, color: CupertinoColors.systemGrey),
|
||||
);
|
||||
}
|
||||
return _buildLocalImage(imagePath, fit);
|
||||
}
|
||||
|
||||
return CachedNetworkImage(
|
||||
imageUrl: imagePath,
|
||||
fit: fit,
|
||||
placeholder: (_, __) => const Center(child: CupertinoActivityIndicator()),
|
||||
errorWidget: (_, __, ___) => const Center(
|
||||
child: Icon(CupertinoIcons.exclamationmark_triangle, color: Colors.red),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 大图预处理 — 超过maxSize时自动压缩 + EXIF旋转校正
|
||||
static Future<Uint8List?> preprocessImage(
|
||||
Uint8List bytes, {
|
||||
int maxSize = 4096,
|
||||
int quality = 95,
|
||||
}) async {
|
||||
try {
|
||||
final info = ImageInfoService.getImageInfoFromBytes(bytes);
|
||||
if (info == null) return bytes;
|
||||
|
||||
final needCompress = info.width > maxSize || info.height > maxSize;
|
||||
final needRotate = info.needRotate;
|
||||
|
||||
if (!needCompress && !needRotate) return bytes;
|
||||
|
||||
Uint8List? result = bytes;
|
||||
|
||||
if (needRotate) {
|
||||
Log.d('EXIF旋转校正: ${info.sizeText} needRotate=true');
|
||||
result = await ImageCompressService.correctExifRotation(bytes);
|
||||
}
|
||||
|
||||
if (needCompress) {
|
||||
Log.d('大图预处理: ${info.sizeText} → ${maxSize}px');
|
||||
result = await ImageCompressService.compressForImport(
|
||||
result ?? bytes,
|
||||
maxWidth: maxSize,
|
||||
maxHeight: maxSize,
|
||||
quality: quality,
|
||||
);
|
||||
}
|
||||
|
||||
return result ?? bytes;
|
||||
} catch (e) {
|
||||
Log.e('图片预处理失败', e);
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
|
||||
/// 读取图片信息 (尺寸+格式+旋转)
|
||||
static ImageInfoResult? getImageInfo(Uint8List bytes) {
|
||||
return ImageInfoService.getImageInfoFromBytes(bytes);
|
||||
}
|
||||
|
||||
/// 构建内存优化图片Widget (ExtendedResizeImage)
|
||||
static Widget buildOptimizedImage(
|
||||
Uint8List bytes, {
|
||||
BoxFit fit = BoxFit.cover,
|
||||
double compressionRatio = 0.5,
|
||||
int? maxBytes,
|
||||
}) {
|
||||
final provider = ExtendedResizeImage(
|
||||
MemoryImage(bytes),
|
||||
compressionRatio: compressionRatio,
|
||||
maxBytes: maxBytes ?? 128 * 1024,
|
||||
);
|
||||
return ExtendedImage(
|
||||
image: provider,
|
||||
fit: fit,
|
||||
clearMemoryCacheWhenDispose: true,
|
||||
);
|
||||
}
|
||||
|
||||
static Widget _buildLocalImage(String path, BoxFit fit) {
|
||||
return _NativeImageLoader.build(path, fit);
|
||||
static Future<Uint8List?> preprocessImage(Uint8List bytes) async {
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
|
||||
class _NativeImageLoader {
|
||||
static Widget build(String path, BoxFit fit) {
|
||||
if (kIsWeb) {
|
||||
return const Center(
|
||||
child: Icon(CupertinoIcons.photo, color: CupertinoColors.systemGrey),
|
||||
);
|
||||
}
|
||||
return _buildNative(path, fit);
|
||||
}
|
||||
|
||||
static Widget _buildNative(String path, BoxFit fit) {
|
||||
try {
|
||||
return _NativeImageWidget(path: path, fit: fit);
|
||||
} catch (_) {
|
||||
return const Center(
|
||||
child: Icon(
|
||||
CupertinoIcons.exclamationmark_triangle,
|
||||
color: CupertinoColors.systemRed,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _NativeImageWidget extends StatelessWidget {
|
||||
final String path;
|
||||
final BoxFit fit;
|
||||
|
||||
const _NativeImageWidget({required this.path, required this.fit});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _buildFileImage(path, fit);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildFileImage(String path, BoxFit fit) {
|
||||
return buildFileImageImpl(path, fit);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// ============================================================
|
||||
// ============================================================
|
||||
// 闲言APP — 成就中心状态管理
|
||||
// 创建时间: 2026-04-29
|
||||
// 更新时间: 2026-04-29
|
||||
@@ -57,8 +57,10 @@ class AchievementState {
|
||||
}
|
||||
}
|
||||
|
||||
class AchievementNotifier extends StateNotifier<AchievementState> {
|
||||
AchievementNotifier() : super(const AchievementState());
|
||||
class AchievementNotifier extends Notifier<AchievementState> {
|
||||
@override
|
||||
AchievementState build() => const AchievementState();
|
||||
AchievementNotifier();
|
||||
|
||||
/// 加载我的成就数据
|
||||
Future<void> loadMyAchievements() async {
|
||||
@@ -125,6 +127,4 @@ class AchievementNotifier extends StateNotifier<AchievementState> {
|
||||
}
|
||||
|
||||
final achievementProvider =
|
||||
StateNotifierProvider<AchievementNotifier, AchievementState>((ref) {
|
||||
return AchievementNotifier();
|
||||
});
|
||||
NotifierProvider<AchievementNotifier, AchievementState>(AchievementNotifier.new);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// ============================================================
|
||||
// ============================================================
|
||||
// 闲言APP — 勋章墙状态管理
|
||||
// 创建时间: 2026-05-14
|
||||
// 更新时间: 2026-05-14
|
||||
@@ -178,8 +178,10 @@ class BadgeState {
|
||||
// Badge Notifier
|
||||
// ============================================================
|
||||
|
||||
class BadgeNotifier extends StateNotifier<BadgeState> {
|
||||
BadgeNotifier() : super(const BadgeState());
|
||||
class BadgeNotifier extends Notifier<BadgeState> {
|
||||
@override
|
||||
BadgeState build() => const BadgeState();
|
||||
BadgeNotifier();
|
||||
|
||||
static final ApiClient _api = ApiClient.instance;
|
||||
static const String _basePath = '/api/achievement';
|
||||
@@ -298,6 +300,4 @@ class BadgeNotifier extends StateNotifier<BadgeState> {
|
||||
// ============================================================
|
||||
|
||||
final badgeProvider =
|
||||
StateNotifierProvider<BadgeNotifier, BadgeState>((ref) {
|
||||
return BadgeNotifier();
|
||||
});
|
||||
NotifierProvider<BadgeNotifier, BadgeState>(BadgeNotifier.new);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// ============================================================
|
||||
// ============================================================
|
||||
// 闲言APP — 学习打卡状态管理
|
||||
// 创建时间: 2026-04-29
|
||||
// 更新时间: 2026-04-29
|
||||
@@ -55,8 +55,10 @@ class CheckinState {
|
||||
}
|
||||
}
|
||||
|
||||
class CheckinNotifier extends StateNotifier<CheckinState> {
|
||||
CheckinNotifier() : super(const CheckinState());
|
||||
class CheckinNotifier extends Notifier<CheckinState> {
|
||||
@override
|
||||
CheckinState build() => const CheckinState();
|
||||
CheckinNotifier();
|
||||
|
||||
/// 加载打卡记录
|
||||
Future<void> loadRecords({bool refresh = false}) async {
|
||||
@@ -99,6 +101,4 @@ class CheckinNotifier extends StateNotifier<CheckinState> {
|
||||
}
|
||||
|
||||
final checkinProvider =
|
||||
StateNotifierProvider<CheckinNotifier, CheckinState>((ref) {
|
||||
return CheckinNotifier();
|
||||
});
|
||||
NotifierProvider<CheckinNotifier, CheckinState>(CheckinNotifier.new);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// ============================================================
|
||||
// ============================================================
|
||||
// 闲言APP — 文章创作状态管理
|
||||
// 创建时间: 2026-04-29
|
||||
// 更新时间: 2026-04-29
|
||||
@@ -84,8 +84,10 @@ class ArticleState {
|
||||
}
|
||||
}
|
||||
|
||||
class ArticleNotifier extends StateNotifier<ArticleState> {
|
||||
ArticleNotifier() : super(const ArticleState());
|
||||
class ArticleNotifier extends Notifier<ArticleState> {
|
||||
@override
|
||||
ArticleState build() => const ArticleState();
|
||||
ArticleNotifier();
|
||||
|
||||
/// 加载文章列表
|
||||
Future<void> loadArticles({
|
||||
@@ -200,6 +202,4 @@ class ArticleNotifier extends StateNotifier<ArticleState> {
|
||||
}
|
||||
|
||||
final articleProvider =
|
||||
StateNotifierProvider<ArticleNotifier, ArticleState>((ref) {
|
||||
return ArticleNotifier();
|
||||
});
|
||||
NotifierProvider<ArticleNotifier, ArticleState>(ArticleNotifier.new);
|
||||
|
||||
@@ -52,10 +52,12 @@ class AuthState {
|
||||
}
|
||||
}
|
||||
|
||||
class AuthNotifier extends StateNotifier<AuthState> {
|
||||
class AuthNotifier extends Notifier<AuthState> {
|
||||
@override
|
||||
AuthState build() => _loadInitialState();
|
||||
static const String _userCacheKey = 'cached_user_info';
|
||||
|
||||
AuthNotifier() : super(_loadInitialState()) {
|
||||
AuthNotifier() {
|
||||
_init();
|
||||
}
|
||||
|
||||
@@ -267,9 +269,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
}
|
||||
}
|
||||
|
||||
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
|
||||
return AuthNotifier();
|
||||
});
|
||||
final authProvider = NotifierProvider<AuthNotifier, AuthState>(AuthNotifier.new);
|
||||
|
||||
final isLoggedInProvider = Provider<bool>((ref) {
|
||||
return ref.watch(authProvider).isLoggedIn;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/// ============================================================
|
||||
/// ============================================================
|
||||
/// 闲言APP — 二维码登录状态管理
|
||||
/// 创建时间: 2026-05-10
|
||||
/// 更新时间: 2026-05-10
|
||||
@@ -66,8 +66,10 @@ class QrcodeLoginState {
|
||||
// ============================================================
|
||||
|
||||
/// 二维码登录状态管理器
|
||||
class QrcodeLoginNotifier extends StateNotifier<QrcodeLoginState> {
|
||||
QrcodeLoginNotifier() : super(const QrcodeLoginState());
|
||||
class QrcodeLoginNotifier extends Notifier<QrcodeLoginState> {
|
||||
@override
|
||||
QrcodeLoginState build() => const QrcodeLoginState();
|
||||
QrcodeLoginNotifier();
|
||||
|
||||
/// 扫码确认登录
|
||||
Future<void> confirmLogin(String code) async {
|
||||
@@ -140,6 +142,4 @@ class QrcodeLoginNotifier extends StateNotifier<QrcodeLoginState> {
|
||||
// ============================================================
|
||||
|
||||
final qrcodeLoginProvider =
|
||||
StateNotifierProvider<QrcodeLoginNotifier, QrcodeLoginState>((ref) {
|
||||
return QrcodeLoginNotifier();
|
||||
});
|
||||
NotifierProvider<QrcodeLoginNotifier, QrcodeLoginState>(QrcodeLoginNotifier.new);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// ============================================================
|
||||
// ============================================================
|
||||
// 闲言APP — 内容查重状态管理
|
||||
// 创建时间: 2026-04-29
|
||||
// 更新时间: 2026-04-29
|
||||
@@ -46,8 +46,10 @@ class CheckState {
|
||||
}
|
||||
}
|
||||
|
||||
class CheckNotifier extends StateNotifier<CheckState> {
|
||||
CheckNotifier() : super(const CheckState());
|
||||
class CheckNotifier extends Notifier<CheckState> {
|
||||
@override
|
||||
CheckState build() => const CheckState();
|
||||
CheckNotifier();
|
||||
|
||||
Future<void> check({required String text}) async {
|
||||
if (text.trim().isEmpty) return;
|
||||
@@ -113,6 +115,4 @@ class CheckNotifier extends StateNotifier<CheckState> {
|
||||
}
|
||||
|
||||
final checkProvider =
|
||||
StateNotifierProvider<CheckNotifier, CheckState>((ref) {
|
||||
return CheckNotifier();
|
||||
});
|
||||
NotifierProvider<CheckNotifier, CheckState>(CheckNotifier.new);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// ============================================================
|
||||
// ============================================================
|
||||
// 闲言APP — 国学经典状态管理
|
||||
// 创建时间: 2026-04-29
|
||||
// 更新时间: 2026-04-29
|
||||
@@ -54,8 +54,10 @@ class ClassicsState {
|
||||
}
|
||||
}
|
||||
|
||||
class ClassicsNotifier extends StateNotifier<ClassicsState> {
|
||||
ClassicsNotifier() : super(const ClassicsState());
|
||||
class ClassicsNotifier extends Notifier<ClassicsState> {
|
||||
@override
|
||||
ClassicsState build() => const ClassicsState();
|
||||
ClassicsNotifier();
|
||||
|
||||
Future<void> loadList({required String type, bool refresh = false}) async {
|
||||
if (state.isLoading) return;
|
||||
@@ -115,6 +117,4 @@ class ClassicsNotifier extends StateNotifier<ClassicsState> {
|
||||
}
|
||||
|
||||
final classicsProvider =
|
||||
StateNotifierProvider<ClassicsNotifier, ClassicsState>((ref) {
|
||||
return ClassicsNotifier();
|
||||
});
|
||||
NotifierProvider<ClassicsNotifier, ClassicsState>(ClassicsNotifier.new);
|
||||
|
||||
@@ -44,12 +44,15 @@ class CanvasPage extends ConsumerStatefulWidget {
|
||||
class _CanvasPageState extends ConsumerState<CanvasPage> {
|
||||
final _repaintBoundaryKey = GlobalKey();
|
||||
bool _hasUnsavedChanges = false;
|
||||
CanvasNotifier? _cachedNotifier;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final notifier = ref.read(canvasProvider.notifier);
|
||||
_cachedNotifier = notifier;
|
||||
notifier.setUserId(widget.userId);
|
||||
final deviceId =
|
||||
ref.read(sharedSignalingProvider).deviceId ?? widget.userId;
|
||||
@@ -63,8 +66,8 @@ class _CanvasPageState extends ConsumerState<CanvasPage> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
final notifier = ref.read(canvasProvider.notifier);
|
||||
notifier.leaveCanvas();
|
||||
_cachedNotifier?.leaveCanvas();
|
||||
_cachedNotifier = null;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -296,7 +299,9 @@ class _CanvasPageState extends ConsumerState<CanvasPage> {
|
||||
await file.writeAsBytes(byteData.buffer.asUint8List());
|
||||
|
||||
if (!mounted) return;
|
||||
await Share.shareXFiles([XFile(file.path)], text: '🎨 闲言协作画布');
|
||||
await SharePlus.instance.share(
|
||||
ShareParams(files: [XFile(file.path)], text: '🎨 闲言协作画布'),
|
||||
);
|
||||
Log.i('CanvasPage: exported PNG to ${file.path}');
|
||||
} catch (e) {
|
||||
Log.e('CanvasPage: export failed: $e');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// ============================================================
|
||||
// ============================================================
|
||||
// 闲言APP — 协作画布Riverpod状态管理
|
||||
// 创建时间: 2026-05-14
|
||||
// 更新时间: 2026-05-15
|
||||
@@ -54,7 +54,9 @@ class CanvasState {
|
||||
}) {
|
||||
return CanvasState(
|
||||
strokes: strokes ?? this.strokes,
|
||||
activeStroke: clearActiveStroke ? null : (activeStroke ?? this.activeStroke),
|
||||
activeStroke: clearActiveStroke
|
||||
? null
|
||||
: (activeStroke ?? this.activeStroke),
|
||||
currentTool: currentTool ?? this.currentTool,
|
||||
currentColor: currentColor ?? this.currentColor,
|
||||
currentWidth: currentWidth ?? this.currentWidth,
|
||||
@@ -66,8 +68,18 @@ class CanvasState {
|
||||
}
|
||||
}
|
||||
|
||||
class CanvasNotifier extends StateNotifier<CanvasState> {
|
||||
CanvasNotifier(this._signaling) : super(const CanvasState()) {
|
||||
class CanvasNotifier extends Notifier<CanvasState> {
|
||||
@override
|
||||
CanvasState build() {
|
||||
ref.onDispose(_onDispose);
|
||||
return const CanvasState();
|
||||
}
|
||||
|
||||
late final SignalingService _signaling = ref.read(sharedSignalingProvider);
|
||||
late final CanvasEngine _engine;
|
||||
late final CanvasSyncService _syncService;
|
||||
|
||||
CanvasNotifier() {
|
||||
_engine = CanvasEngine();
|
||||
_syncService = CanvasSyncService(signaling: _signaling);
|
||||
|
||||
@@ -77,12 +89,9 @@ class CanvasNotifier extends StateNotifier<CanvasState> {
|
||||
_syncService.onRemoteSnapshot = _handleRemoteSnapshot;
|
||||
_syncService.onRemoteCursor = _handleRemoteCursor;
|
||||
_syncService.onParticipantsChanged = _handleParticipantsChanged;
|
||||
_syncService.onSnapshotRequest = _handleSnapshotRequest;
|
||||
}
|
||||
|
||||
final SignalingService _signaling;
|
||||
late final CanvasEngine _engine;
|
||||
late final CanvasSyncService _syncService;
|
||||
|
||||
CanvasEngine get engine => _engine;
|
||||
|
||||
void setUserId(String userId) {
|
||||
@@ -130,10 +139,7 @@ class CanvasNotifier extends StateNotifier<CanvasState> {
|
||||
|
||||
void joinCanvas(String canvasId, String deviceId, {String? peerDeviceId}) {
|
||||
_syncService.joinCanvas(canvasId, deviceId, peerId: peerDeviceId);
|
||||
state = state.copyWith(
|
||||
canvasId: canvasId,
|
||||
isConnected: true,
|
||||
);
|
||||
state = state.copyWith(canvasId: canvasId, isConnected: true);
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
_syncService.requestSnapshot();
|
||||
});
|
||||
@@ -179,16 +185,22 @@ class CanvasNotifier extends StateNotifier<CanvasState> {
|
||||
state = state.copyWith(participants: participants);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
void _handleSnapshotRequest(String requestingDeviceId, String canvasId) {
|
||||
if (state.canvasId != canvasId) return;
|
||||
final strokes = _engine.strokes;
|
||||
Log.i(
|
||||
'CanvasProvider: sending snapshot response to $requestingDeviceId with ${strokes.length} strokes',
|
||||
);
|
||||
_syncService.sendSnapshotResponse(requestingDeviceId, strokes);
|
||||
}
|
||||
|
||||
void _onDispose() {
|
||||
_engine.removeListener(_onEngineChanged);
|
||||
_engine.dispose();
|
||||
_syncService.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
final canvasProvider = StateNotifierProvider<CanvasNotifier, CanvasState>((ref) {
|
||||
final signaling = ref.watch(sharedSignalingProvider);
|
||||
return CanvasNotifier(signaling);
|
||||
});
|
||||
final canvasProvider = NotifierProvider<CanvasNotifier, CanvasState>(
|
||||
CanvasNotifier.new,
|
||||
);
|
||||
|
||||
@@ -18,6 +18,8 @@ typedef StrokeCallback = void Function(Stroke stroke);
|
||||
typedef SnapshotCallback = void Function(List<Stroke> strokes);
|
||||
typedef CursorCallback = void Function(String userId, Offset position);
|
||||
typedef ParticipantsCallback = void Function(List<String> participantIds);
|
||||
typedef SnapshotRequestCallback =
|
||||
void Function(String requestingDeviceId, String canvasId);
|
||||
|
||||
class CanvasSyncService {
|
||||
CanvasSyncService({required SignalingService signaling})
|
||||
@@ -36,6 +38,7 @@ class CanvasSyncService {
|
||||
SnapshotCallback? onRemoteSnapshot;
|
||||
CursorCallback? onRemoteCursor;
|
||||
ParticipantsCallback? onParticipantsChanged;
|
||||
SnapshotRequestCallback? onSnapshotRequest;
|
||||
|
||||
void joinCanvas(String canvasId, String deviceId, {String? peerId}) {
|
||||
_canvasId = canvasId;
|
||||
@@ -165,6 +168,8 @@ class CanvasSyncService {
|
||||
|
||||
if (action == 'request') {
|
||||
Log.i('CanvasSync: snapshot request from ${message.from}');
|
||||
final requestCanvasId = payload['canvasId'] as String? ?? '';
|
||||
onSnapshotRequest?.call(message.from, requestCanvasId);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||