修复 断点异常
This commit is contained in:
133
lib/main.dart
133
lib/main.dart
@@ -1,68 +1,57 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:catcher_2/catcher_2.dart';
|
||||
import 'package:mom_kitchen/src/l10n/app_localizations.dart';
|
||||
|
||||
// 📋 页面注册系统 - 管理路由和页面定义
|
||||
// 文件: lib/src/config/app_routes.dart
|
||||
import 'package:mom_kitchen/src/config/app_routes.dart';
|
||||
|
||||
import 'package:mom_kitchen/src/services/core/app_service.dart';
|
||||
import 'package:mom_kitchen/src/services/orientation_service.dart';
|
||||
import 'package:mom_kitchen/src/services/ui/theme_service.dart';
|
||||
import 'package:mom_kitchen/src/services/ui/toast_service.dart';
|
||||
import 'package:mom_kitchen/src/services/crash_guard_service.dart';
|
||||
|
||||
// 📋 页面注册表和验证器 - 管理页面注册和规范验证
|
||||
// 文件: lib/src/standards/page_validator.dart
|
||||
import 'package:mom_kitchen/src/standards/page_validator.dart';
|
||||
|
||||
// 📋 全局 Binding - 统一管理 Controller 和 Service 生命周期
|
||||
import 'package:mom_kitchen/src/app_binding.dart';
|
||||
|
||||
import 'package:mom_kitchen/src/utils/app_logger.dart';
|
||||
|
||||
void main() async {
|
||||
// 确保 Flutter 引擎初始化
|
||||
debugPrint('🚀 开始初始化 Flutter 引擎...');
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
debugPrint('✅ Flutter 引擎初始化完成');
|
||||
// 2026-04-15 | main.dart | 应用入口 | Catcher2最先初始化+串行化启动流程防ANR
|
||||
void main() {
|
||||
final crashGuard = CrashGuardService();
|
||||
Catcher2(
|
||||
runAppFunction: () async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
FlutterError.onError = (FlutterErrorDetails details) {
|
||||
FlutterError.presentError(details);
|
||||
debugPrint('=== FLUTTER ERROR STACK TRACE ===');
|
||||
debugPrint(details.stack.toString());
|
||||
debugPrint('=== END STACK TRACE ===');
|
||||
};
|
||||
await _initApp();
|
||||
|
||||
// 初始化所有服务
|
||||
debugPrint('📱 开始初始化应用服务...');
|
||||
runApp(const MyApp());
|
||||
},
|
||||
ensureInitialized: false,
|
||||
debugConfig: crashGuard.buildDebugOptions(),
|
||||
releaseConfig: crashGuard.buildReleaseOptions(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _initApp() async {
|
||||
try {
|
||||
await AppService.instance.init();
|
||||
debugPrint('✅ 应用服务初始化完成');
|
||||
} catch (e) {
|
||||
debugPrint('❌ 应用服务初始化失败: $e');
|
||||
rethrow;
|
||||
debugPrint('❌ AppService初始化失败: $e');
|
||||
}
|
||||
|
||||
try {
|
||||
await CrashGuardService.init();
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ CrashGuardService初始化失败: $e');
|
||||
}
|
||||
|
||||
// 📋 页面注册入口 - 在此处调用注册所有页面
|
||||
if (kDebugMode) {
|
||||
debugPrint('📋 开始注册页面...');
|
||||
AppRoutes.registerAllPages();
|
||||
debugPrint('✅ 页面注册完成,共注册 ${PageRegistry.pageCount} 个页面');
|
||||
|
||||
final config = PageRegistry.exportConfig();
|
||||
debugPrint('📊 页面配置: $config');
|
||||
}
|
||||
|
||||
debugPrint('🔄 解锁屏幕方向...');
|
||||
await OrientationService().unlockOrientation();
|
||||
debugPrint('✅ 屏幕方向解锁完成');
|
||||
|
||||
// 运行应用
|
||||
debugPrint('🎯 启动应用...');
|
||||
runApp(const MyApp());
|
||||
debugPrint('✅ 应用启动完成');
|
||||
}
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
@@ -76,18 +65,11 @@ class MyApp extends StatelessWidget {
|
||||
final textScale = themeService.fontSize.value / 16.0;
|
||||
return GetCupertinoApp(
|
||||
title: 'Mom\'s Kitchen',
|
||||
key: ValueKey(
|
||||
'theme_${themeService.primaryColor.value.toARGB32()}_${themeService.isDarkMode.value}_${themeService.fontSize.value}',
|
||||
),
|
||||
navigatorKey: Catcher2.navigatorKey,
|
||||
theme: themeService.cupertinoThemeData,
|
||||
locale: Locale(themeService.currentLocale.value),
|
||||
debugShowCheckedModeBanner: false,
|
||||
localizationsDelegates: const [
|
||||
AppLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
],
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: const [
|
||||
Locale('en'),
|
||||
Locale('zh'),
|
||||
@@ -96,14 +78,13 @@ class MyApp extends StatelessWidget {
|
||||
getPages: AppRoutes.pages,
|
||||
initialBinding: AppBinding(),
|
||||
builder: (context, widget) {
|
||||
final theme = ThemeService.instance;
|
||||
return MediaQuery(
|
||||
data: MediaQuery.of(
|
||||
context,
|
||||
).copyWith(textScaler: TextScaler.linear(textScale)),
|
||||
child: ColoredBox(
|
||||
color: theme.backgroundColor.value,
|
||||
child: widget!,
|
||||
color: themeService.backgroundColor.value,
|
||||
child: widget ?? const SizedBox.shrink(),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -127,14 +108,36 @@ class _InitWrapper extends StatefulWidget {
|
||||
|
||||
class _InitWrapperState extends State<_InitWrapper>
|
||||
with WidgetsBindingObserver {
|
||||
bool _navigated = false;
|
||||
int _retryCount = 0;
|
||||
static const int _maxRetries = 3;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_navigateToMain();
|
||||
}
|
||||
|
||||
void _navigateToMain() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
if (!mounted || _navigated) return;
|
||||
try {
|
||||
ToastService.init(context);
|
||||
Get.offAllNamed(AppRoutes.main);
|
||||
_navigated = true;
|
||||
debugPrint('✅ 导航到主页面成功');
|
||||
} catch (e) {
|
||||
_retryCount++;
|
||||
debugPrint('❌ 导航失败 (第$_retryCount次): $e');
|
||||
if (_retryCount < _maxRetries) {
|
||||
Future.delayed(Duration(milliseconds: 500 * _retryCount), () {
|
||||
if (mounted && !_navigated) _navigateToMain();
|
||||
});
|
||||
} else {
|
||||
debugPrint('🚨 导航重试已达上限,显示错误页面');
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -150,12 +153,38 @@ class _InitWrapperState extends State<_InitWrapper>
|
||||
super.didChangePlatformBrightness();
|
||||
final brightness =
|
||||
WidgetsBinding.instance.platformDispatcher.platformBrightness;
|
||||
final themeService = Get.find<ThemeService>();
|
||||
themeService.onSystemBrightnessChanged(brightness);
|
||||
try {
|
||||
final themeService = Get.find<ThemeService>();
|
||||
themeService.onSystemBrightnessChanged(brightness);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_retryCount >= _maxRetries) {
|
||||
return CupertinoPageScaffold(
|
||||
child: SafeArea(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(CupertinoIcons.exclamationmark_triangle, size: 48),
|
||||
const SizedBox(height: 16),
|
||||
const Text('启动失败,请重试', style: TextStyle(fontSize: 16)),
|
||||
const SizedBox(height: 24),
|
||||
CupertinoButton.filled(
|
||||
onPressed: () {
|
||||
_retryCount = 0;
|
||||
_navigateToMain();
|
||||
},
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const CupertinoPageScaffold(
|
||||
child: Center(child: CupertinoActivityIndicator()),
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// 2026-04-10 | 移除 CartController 注册(收藏功能统一使用 FavoritesController)
|
||||
// 2026-04-10 | 新增 ShoppingListController 全局注册(首页需要使用)
|
||||
// 2026-04-11 | 统一控制器生命周期管理 | 新增ToolsController/HotController/WhatToEatController全局注册 | 移除路由级重复注册
|
||||
// 2026-04-15 | 修复ANR: 非首屏控制器改用lazyPut延迟初始化,避免5个网络请求同时发出导致原生ANR杀进程
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:mom_kitchen/src/controllers/feed/action_controller.dart';
|
||||
@@ -27,6 +28,10 @@ import 'package:mom_kitchen/src/services/ui/theme_service.dart';
|
||||
|
||||
/// 全局Binding - 应用启动时注册所有全局控制器和服务
|
||||
/// 所有 permanent:true 的控制器在此统一管理,路由级Binding禁止重复注册
|
||||
///
|
||||
/// 初始化策略:
|
||||
/// - Get.put: 首屏必需的控制器,立即初始化
|
||||
/// - Get.lazyPut: 非首屏控制器,首次访问时才初始化(避免ANR)
|
||||
class AppBinding extends Bindings {
|
||||
@override
|
||||
void dependencies() {
|
||||
@@ -42,26 +47,29 @@ class AppBinding extends Bindings {
|
||||
Get.lazyPut(() => AppService.instance.appInfo, fenix: true);
|
||||
Get.lazyPut(() => AppService.instance.toast, fenix: true);
|
||||
|
||||
// --- 主题与个性化 ---
|
||||
// --- 主题与个性化(首屏必需) ---
|
||||
Get.put(ThemeService.instance, permanent: true);
|
||||
Get.put(PersonalizationController(), permanent: true);
|
||||
|
||||
// --- 核心业务控制器(全局永久,路由级Binding中禁止重复注册) ---
|
||||
Get.put(ActionController(), permanent: true);
|
||||
Get.put(FavoritesController(), permanent: true);
|
||||
Get.put(ShoppingListController(), permanent: true);
|
||||
Get.put(HomeController(), permanent: true);
|
||||
Get.put(FeedController(), permanent: true);
|
||||
Get.put(PreferenceController(), permanent: true);
|
||||
Get.put(ProfileController(), permanent: true);
|
||||
// --- 首屏必需控制器(立即初始化,仅HomeController发网络请求) ---
|
||||
Get.put(MainNavigationController(), permanent: true);
|
||||
Get.put(ToolsController(), permanent: true);
|
||||
Get.put(HotController(), permanent: true);
|
||||
Get.put(WhatToEatController(), permanent: true);
|
||||
Get.put(CookingNoteController(), permanent: true);
|
||||
Get.put(BrowseHistoryController(), permanent: true);
|
||||
Get.put(WeeklyMenuController(), permanent: true);
|
||||
Get.put(BedtimeReminderController(), permanent: true);
|
||||
Get.put(HomeController(), permanent: true);
|
||||
Get.put(ActionController(), permanent: true);
|
||||
|
||||
// --- 非首屏控制器(延迟初始化,切换Tab时才创建,避免ANR) ---
|
||||
Get.lazyPut(() => FeedController(), fenix: true);
|
||||
Get.lazyPut(() => HotController(), fenix: true);
|
||||
Get.lazyPut(() => WhatToEatController(), fenix: true);
|
||||
Get.lazyPut(() => ToolsController(), fenix: true);
|
||||
Get.lazyPut(() => PreferenceController(), fenix: true);
|
||||
Get.lazyPut(() => ProfileController(), fenix: true);
|
||||
Get.lazyPut(() => FavoritesController(), fenix: true);
|
||||
Get.lazyPut(() => ShoppingListController(), fenix: true);
|
||||
Get.lazyPut(() => CookingNoteController(), fenix: true);
|
||||
Get.lazyPut(() => BrowseHistoryController(), fenix: true);
|
||||
Get.lazyPut(() => WeeklyMenuController(), fenix: true);
|
||||
Get.lazyPut(() => BedtimeReminderController(), fenix: true);
|
||||
Get.lazyPut(() => MealRecordController(), fenix: true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ import 'package:mom_kitchen/src/pages/tools/planning/daily_menu_page.dart';
|
||||
import 'package:mom_kitchen/src/pages/profile/bedtime_reminder_page.dart';
|
||||
import 'package:mom_kitchen/src/pages/tools/cooking_tips_list_page.dart';
|
||||
import 'package:mom_kitchen/src/pages/profile/references_page.dart';
|
||||
import 'package:mom_kitchen/src/pages/profile/social/email_history_page.dart';
|
||||
import 'package:mom_kitchen/src/app_binding.dart';
|
||||
|
||||
class AppRoutes {
|
||||
@@ -101,6 +102,7 @@ class AppRoutes {
|
||||
static const String references = '/references';
|
||||
static const String nutritionRecipeList = '/nutrition-recipe-list';
|
||||
static const String miniCard = '/mini-card';
|
||||
static const String emailHistory = '/email-history';
|
||||
|
||||
static final List<GetPage> pages = [
|
||||
GetPage(
|
||||
@@ -430,6 +432,11 @@ class AppRoutes {
|
||||
},
|
||||
middlewares: [PageStandardsMiddleware()],
|
||||
),
|
||||
GetPage(
|
||||
name: emailHistory,
|
||||
page: () => const EmailHistoryPage(),
|
||||
middlewares: [PageStandardsMiddleware()],
|
||||
),
|
||||
];
|
||||
|
||||
static void registerAllPages() {
|
||||
@@ -1009,6 +1016,18 @@ class AppRoutes {
|
||||
],
|
||||
builder: () => const MealPlannerPage(),
|
||||
),
|
||||
PageInfo(
|
||||
route: emailHistory,
|
||||
name: 'Email History Page',
|
||||
description: '发件记录页面',
|
||||
requiredStandards: const [
|
||||
StandardCheck.themeColors,
|
||||
StandardCheck.textColors,
|
||||
StandardCheck.fontSize,
|
||||
StandardCheck.darkMode,
|
||||
],
|
||||
builder: () => const EmailHistoryPage(),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
139
lib/src/controllers/data/email_history_controller.dart
Normal file
139
lib/src/controllers/data/email_history_controller.dart
Normal file
@@ -0,0 +1,139 @@
|
||||
// 2026-04-15 | email_history_controller.dart | 邮件记录控制器 | 管理邮件发送历史
|
||||
// 2026-04-15 | 初始创建,使用 SharedPreferences JSON 持久化
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'dart:convert';
|
||||
import '../../models/data/email_record_model.dart';
|
||||
|
||||
class EmailHistoryController extends GetxController {
|
||||
static EmailHistoryController get to => Get.find();
|
||||
|
||||
final RxList<EmailRecordModel> _records = <EmailRecordModel>[].obs;
|
||||
static const String _storageKey = 'email_history';
|
||||
static const int _maxRecordCount = 200;
|
||||
|
||||
List<EmailRecordModel> get records => _records;
|
||||
|
||||
/// 成功发送数量
|
||||
int get successCount =>
|
||||
_records.where((r) => r.status == EmailSendStatus.success).length;
|
||||
|
||||
/// 失败发送数量
|
||||
int get failedCount =>
|
||||
_records.where((r) => r.status == EmailSendStatus.failed).length;
|
||||
|
||||
SharedPreferences? _prefs;
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
_initPrefs();
|
||||
}
|
||||
|
||||
Future<void> _initPrefs() async {
|
||||
try {
|
||||
_prefs = await SharedPreferences.getInstance();
|
||||
await loadRecords();
|
||||
debugPrint('邮件记录初始化完成,共 ${_records.length} 条记录');
|
||||
} catch (e) {
|
||||
debugPrint('初始化SharedPreferences失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 从本地加载记录
|
||||
Future<void> loadRecords() async {
|
||||
try {
|
||||
if (_prefs == null) {
|
||||
_prefs = await SharedPreferences.getInstance();
|
||||
}
|
||||
final String? data = _prefs!.getString(_storageKey);
|
||||
if (data != null && data.isNotEmpty) {
|
||||
final List<dynamic> jsonList = json.decode(data);
|
||||
final loaded =
|
||||
jsonList.map((json) => EmailRecordModel.fromJson(json)).toList();
|
||||
_records.assignAll(loaded);
|
||||
debugPrint('从本地加载 ${loaded.length} 条邮件记录');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('加载邮件记录失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 添加发送记录
|
||||
Future<void> addRecord(EmailRecordModel record) async {
|
||||
try {
|
||||
_records.insert(0, record);
|
||||
if (_records.length > _maxRecordCount) {
|
||||
_records.removeRange(_maxRecordCount, _records.length);
|
||||
}
|
||||
await _saveRecords();
|
||||
} catch (e) {
|
||||
debugPrint('添加邮件记录失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新记录状态(如发送完成后更新)
|
||||
Future<void> updateRecordStatus(
|
||||
String id,
|
||||
EmailSendStatus status, {
|
||||
String? errorMessage,
|
||||
}) async {
|
||||
try {
|
||||
final index = _records.indexWhere((r) => r.id == id);
|
||||
if (index >= 0) {
|
||||
_records[index] = _records[index].copyWith(
|
||||
status: status,
|
||||
errorMessage: errorMessage,
|
||||
);
|
||||
await _saveRecords();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('更新邮件记录状态失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除单条记录
|
||||
Future<void> removeRecord(String id) async {
|
||||
try {
|
||||
_records.removeWhere((r) => r.id == id);
|
||||
await _saveRecords();
|
||||
} catch (e) {
|
||||
debugPrint('删除邮件记录失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 清空所有记录
|
||||
Future<void> clearRecords() async {
|
||||
try {
|
||||
_records.clear();
|
||||
await _saveRecords();
|
||||
} catch (e) {
|
||||
debugPrint('清空邮件记录失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存到本地
|
||||
Future<void> _saveRecords() async {
|
||||
try {
|
||||
if (_prefs == null) {
|
||||
_prefs = await SharedPreferences.getInstance();
|
||||
}
|
||||
final data = json.encode(_records.map((r) => r.toJson()).toList());
|
||||
await _prefs!.setString(_storageKey, data);
|
||||
} catch (e) {
|
||||
debugPrint('保存邮件记录失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 按日期筛选
|
||||
List<EmailRecordModel> getRecordsByDate(String date) {
|
||||
return _records.where((r) => r.sentAt.startsWith(date)).toList();
|
||||
}
|
||||
|
||||
/// 按状态筛选
|
||||
List<EmailRecordModel> getRecordsByStatus(EmailSendStatus status) {
|
||||
return _records.where((r) => r.status == status).toList();
|
||||
}
|
||||
}
|
||||
@@ -24,8 +24,8 @@ class WeeklyMenuController extends BaseController {
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
_loadCurrentWeekMenu();
|
||||
_selectToday();
|
||||
Future.delayed(Duration.zero, () => _loadCurrentWeekMenu());
|
||||
}
|
||||
|
||||
void _selectToday() {
|
||||
@@ -54,7 +54,9 @@ class WeeklyMenuController extends BaseController {
|
||||
final data = hive.get(_hiveBox, weekId);
|
||||
|
||||
if (data != null) {
|
||||
currentMenu.value = WeeklyMenuModel.fromJson(data);
|
||||
currentMenu.value = WeeklyMenuModel.fromJson(
|
||||
Map<String, dynamic>.from(data),
|
||||
);
|
||||
} else {
|
||||
_createNewWeekMenu();
|
||||
}
|
||||
@@ -97,7 +99,11 @@ class WeeklyMenuController extends BaseController {
|
||||
if (!hive.isInitialized) return;
|
||||
|
||||
if (currentMenu.value != null) {
|
||||
hive.put(_hiveBox, currentMenu.value!.weekId, currentMenu.value!.toJson());
|
||||
hive.put(
|
||||
_hiveBox,
|
||||
currentMenu.value!.weekId,
|
||||
currentMenu.value!.toJson(),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Save weekly menu error: $e');
|
||||
@@ -134,7 +140,9 @@ class WeeklyMenuController extends BaseController {
|
||||
ingredients: ingredients,
|
||||
);
|
||||
|
||||
final updatedMenus = Map<String, DayMenu>.from(currentMenu.value!.dailyMenus);
|
||||
final updatedMenus = Map<String, DayMenu>.from(
|
||||
currentMenu.value!.dailyMenus,
|
||||
);
|
||||
|
||||
switch (mealType) {
|
||||
case 'breakfast':
|
||||
@@ -179,7 +187,9 @@ class WeeklyMenuController extends BaseController {
|
||||
final dayMenu = currentMenu.value!.dailyMenus[dateKey];
|
||||
if (dayMenu == null) return;
|
||||
|
||||
final updatedMenus = Map<String, DayMenu>.from(currentMenu.value!.dailyMenus);
|
||||
final updatedMenus = Map<String, DayMenu>.from(
|
||||
currentMenu.value!.dailyMenus,
|
||||
);
|
||||
|
||||
switch (mealType) {
|
||||
case 'breakfast':
|
||||
@@ -224,7 +234,9 @@ class WeeklyMenuController extends BaseController {
|
||||
final dayMenu = currentMenu.value!.dailyMenus[dateKey];
|
||||
if (dayMenu == null) return;
|
||||
|
||||
final updatedMenus = Map<String, DayMenu>.from(currentMenu.value!.dailyMenus);
|
||||
final updatedMenus = Map<String, DayMenu>.from(
|
||||
currentMenu.value!.dailyMenus,
|
||||
);
|
||||
|
||||
// 更新所有餐次的食材状态
|
||||
MealItem? updateMealIngredients(MealItem? meal) {
|
||||
@@ -280,7 +292,8 @@ class WeeklyMenuController extends BaseController {
|
||||
if (ingredientMap.containsKey(key)) {
|
||||
// 合并相同食材
|
||||
final existing = ingredientMap[key]!;
|
||||
final newAmount = (double.tryParse(existing.amount) ?? 0) +
|
||||
final newAmount =
|
||||
(double.tryParse(existing.amount) ?? 0) +
|
||||
(double.tryParse(ingredient.amount) ?? 0);
|
||||
ingredientMap[key] = ShoppingIngredient(
|
||||
name: existing.name,
|
||||
|
||||
@@ -15,7 +15,7 @@ class ActionController extends BaseController {
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
_loadIpStatus();
|
||||
Future.delayed(const Duration(milliseconds: 500), () => _loadIpStatus());
|
||||
}
|
||||
|
||||
Future<void> _loadIpStatus() async {
|
||||
|
||||
@@ -20,7 +20,7 @@ class FeedController extends BaseController {
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
loadFeed();
|
||||
Future.delayed(Duration.zero, () => loadFeed());
|
||||
}
|
||||
|
||||
void switchFeed(FeedType type) {
|
||||
|
||||
@@ -18,7 +18,7 @@ class HotController extends BaseController {
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
loadHot();
|
||||
Future.delayed(Duration.zero, () => loadHot());
|
||||
}
|
||||
|
||||
void switchPeriod(HotPeriod period) {
|
||||
|
||||
@@ -20,9 +20,12 @@ class HomeController extends BaseController {
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
_loadInitialData().catchError((e, stackTrace) {
|
||||
debugPrint('HomeController init error: $e');
|
||||
});
|
||||
Future.delayed(
|
||||
Duration.zero,
|
||||
() => _loadInitialData().catchError((e, stackTrace) {
|
||||
debugPrint('HomeController init error: $e');
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadInitialData() async {
|
||||
|
||||
@@ -47,7 +47,7 @@ class WhatToEatController extends BaseController {
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
_loadOptionsSafe();
|
||||
Future.delayed(Duration.zero, () => _loadOptionsSafe());
|
||||
}
|
||||
|
||||
Future<void> _loadOptionsSafe() async {
|
||||
|
||||
@@ -15,8 +15,10 @@ class OnlineController extends BaseController {
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
loadOnlineStats();
|
||||
startHeartbeat();
|
||||
Future.delayed(Duration.zero, () {
|
||||
loadOnlineStats();
|
||||
startHeartbeat();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -28,10 +28,13 @@ class PreferenceController extends BaseController {
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
_initializeUser().catchError((e, stackTrace) {
|
||||
debugPrint('PreferenceController init error: $e');
|
||||
availableAllergens.value = AllergenItem.defaults;
|
||||
});
|
||||
Future.delayed(
|
||||
Duration.zero,
|
||||
() => _initializeUser().catchError((e, stackTrace) {
|
||||
debugPrint('PreferenceController init error: $e');
|
||||
availableAllergens.value = AllergenItem.defaults;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _initializeUser() async {
|
||||
|
||||
180
lib/src/models/data/email_record_model.dart
Normal file
180
lib/src/models/data/email_record_model.dart
Normal file
@@ -0,0 +1,180 @@
|
||||
// 2026-04-15 | email_record_model.dart | 邮件发送记录模型 | 记录菜谱邮件分享历史
|
||||
// 2026-04-15 | 初始创建,支持 SharedPreferences JSON 持久化
|
||||
|
||||
/// 邮件发送状态
|
||||
enum EmailSendStatus {
|
||||
/// 发送成功
|
||||
success,
|
||||
/// 发送失败
|
||||
failed,
|
||||
/// 发送中
|
||||
sending,
|
||||
}
|
||||
|
||||
/// 邮件发送记录模型
|
||||
class EmailRecordModel {
|
||||
/// 唯一标识
|
||||
final String id;
|
||||
|
||||
/// 菜谱ID
|
||||
final String recipeId;
|
||||
|
||||
/// 菜谱标题
|
||||
final String recipeTitle;
|
||||
|
||||
/// 收件人邮箱
|
||||
final String recipientEmail;
|
||||
|
||||
/// 发件人邮箱(SMTP账号)
|
||||
final String senderEmail;
|
||||
|
||||
/// SMTP服务器
|
||||
final String smtpHost;
|
||||
|
||||
/// SMTP端口
|
||||
final int smtpPort;
|
||||
|
||||
/// 使用的线路名称
|
||||
final String routeName;
|
||||
|
||||
/// 邮件主题
|
||||
final String subject;
|
||||
|
||||
/// 发送状态
|
||||
final EmailSendStatus status;
|
||||
|
||||
/// 发送时间(ISO8601字符串)
|
||||
final String sentAt;
|
||||
|
||||
/// 错误信息(发送失败时)
|
||||
final String? errorMessage;
|
||||
|
||||
const EmailRecordModel({
|
||||
required this.id,
|
||||
required this.recipeId,
|
||||
required this.recipeTitle,
|
||||
required this.recipientEmail,
|
||||
required this.senderEmail,
|
||||
required this.smtpHost,
|
||||
required this.smtpPort,
|
||||
required this.routeName,
|
||||
required this.subject,
|
||||
required this.status,
|
||||
required this.sentAt,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
/// 友好的时间显示
|
||||
String get displayDate {
|
||||
if (sentAt.isEmpty) return '';
|
||||
try {
|
||||
final dt = DateTime.parse(sentAt);
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(dt);
|
||||
|
||||
if (diff.inDays == 0) {
|
||||
if (diff.inHours == 0) {
|
||||
if (diff.inMinutes == 0) return '刚刚';
|
||||
return '${diff.inMinutes}分钟前';
|
||||
}
|
||||
return '${diff.inHours}小时前';
|
||||
} else if (diff.inDays == 1) {
|
||||
return '昨天';
|
||||
} else if (diff.inDays < 7) {
|
||||
return '${diff.inDays}天前';
|
||||
} else {
|
||||
return '${dt.month}月${dt.day}日';
|
||||
}
|
||||
} catch (_) {
|
||||
return sentAt;
|
||||
}
|
||||
}
|
||||
|
||||
/// 状态图标
|
||||
String get statusIcon {
|
||||
switch (status) {
|
||||
case EmailSendStatus.success:
|
||||
return '✅';
|
||||
case EmailSendStatus.failed:
|
||||
return '❌';
|
||||
case EmailSendStatus.sending:
|
||||
return '⏳';
|
||||
}
|
||||
}
|
||||
|
||||
/// 状态文字
|
||||
String get statusText {
|
||||
switch (status) {
|
||||
case EmailSendStatus.success:
|
||||
return '已发送';
|
||||
case EmailSendStatus.failed:
|
||||
return '发送失败';
|
||||
case EmailSendStatus.sending:
|
||||
return '发送中';
|
||||
}
|
||||
}
|
||||
|
||||
EmailRecordModel copyWith({
|
||||
String? id,
|
||||
String? recipeId,
|
||||
String? recipeTitle,
|
||||
String? recipientEmail,
|
||||
String? senderEmail,
|
||||
String? smtpHost,
|
||||
int? smtpPort,
|
||||
String? routeName,
|
||||
String? subject,
|
||||
EmailSendStatus? status,
|
||||
String? sentAt,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return EmailRecordModel(
|
||||
id: id ?? this.id,
|
||||
recipeId: recipeId ?? this.recipeId,
|
||||
recipeTitle: recipeTitle ?? this.recipeTitle,
|
||||
recipientEmail: recipientEmail ?? this.recipientEmail,
|
||||
senderEmail: senderEmail ?? this.senderEmail,
|
||||
smtpHost: smtpHost ?? this.smtpHost,
|
||||
smtpPort: smtpPort ?? this.smtpPort,
|
||||
routeName: routeName ?? this.routeName,
|
||||
subject: subject ?? this.subject,
|
||||
status: status ?? this.status,
|
||||
sentAt: sentAt ?? this.sentAt,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'recipeId': recipeId,
|
||||
'recipeTitle': recipeTitle,
|
||||
'recipientEmail': recipientEmail,
|
||||
'senderEmail': senderEmail,
|
||||
'smtpHost': smtpHost,
|
||||
'smtpPort': smtpPort,
|
||||
'routeName': routeName,
|
||||
'subject': subject,
|
||||
'status': status.index,
|
||||
'sentAt': sentAt,
|
||||
'errorMessage': errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
factory EmailRecordModel.fromJson(Map<String, dynamic> json) {
|
||||
return EmailRecordModel(
|
||||
id: json['id'] as String? ?? '',
|
||||
recipeId: json['recipeId'] as String? ?? '',
|
||||
recipeTitle: json['recipeTitle'] as String? ?? '',
|
||||
recipientEmail: json['recipientEmail'] as String? ?? '',
|
||||
senderEmail: json['senderEmail'] as String? ?? '',
|
||||
smtpHost: json['smtpHost'] as String? ?? '',
|
||||
smtpPort: json['smtpPort'] as int? ?? 465,
|
||||
routeName: json['routeName'] as String? ?? '',
|
||||
subject: json['subject'] as String? ?? '',
|
||||
status: EmailSendStatus.values[json['status'] as int? ?? 0],
|
||||
sentAt: json['sentAt'] as String? ?? '',
|
||||
errorMessage: json['errorMessage'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@
|
||||
* 更新: 2026-04-11 初始实现
|
||||
*/
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class WeeklyMenuModel {
|
||||
final String weekId;
|
||||
final DateTime startDate;
|
||||
@@ -19,14 +21,27 @@ class WeeklyMenuModel {
|
||||
|
||||
factory WeeklyMenuModel.fromJson(Map<String, dynamic> json) {
|
||||
final dailyMenus = <String, DayMenu>{};
|
||||
if (json['daily_menus'] != null) {
|
||||
(json['daily_menus'] as Map<String, dynamic>).forEach((key, value) {
|
||||
dailyMenus[key] = DayMenu.fromJson(value as Map<String, dynamic>);
|
||||
});
|
||||
try {
|
||||
if (json['daily_menus'] != null && json['daily_menus'] is Map) {
|
||||
final menusMap = json['daily_menus'] as Map;
|
||||
for (final entry in menusMap.entries) {
|
||||
if (entry.key is String && entry.value is Map) {
|
||||
try {
|
||||
dailyMenus[entry.key.toString()] = DayMenu.fromJson(
|
||||
Map<String, dynamic>.from(entry.value as Map),
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('Failed to parse DayMenu: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Failed to parse dailyMenus: $e');
|
||||
}
|
||||
|
||||
return WeeklyMenuModel(
|
||||
weekId: json['week_id'] ?? '',
|
||||
weekId: json['week_id']?.toString() ?? '',
|
||||
startDate: _parseDateTime(json['start_date']),
|
||||
dailyMenus: dailyMenus,
|
||||
);
|
||||
@@ -75,13 +90,23 @@ class DayMenu {
|
||||
|
||||
factory DayMenu.fromJson(Map<String, dynamic> json) {
|
||||
return DayMenu(
|
||||
dateKey: json['date_key'] ?? '',
|
||||
breakfast: json['breakfast'] != null ? MealItem.fromJson(json['breakfast']) : null,
|
||||
lunch: json['lunch'] != null ? MealItem.fromJson(json['lunch']) : null,
|
||||
dinner: json['dinner'] != null ? MealItem.fromJson(json['dinner']) : null,
|
||||
dateKey: json['date_key']?.toString() ?? '',
|
||||
breakfast: _safeParseMealItem(json['breakfast']),
|
||||
lunch: _safeParseMealItem(json['lunch']),
|
||||
dinner: _safeParseMealItem(json['dinner']),
|
||||
);
|
||||
}
|
||||
|
||||
static MealItem? _safeParseMealItem(dynamic data) {
|
||||
if (data == null || data is! Map) return null;
|
||||
try {
|
||||
return MealItem.fromJson(Map<String, dynamic>.from(data));
|
||||
} catch (e) {
|
||||
debugPrint('Failed to parse MealItem: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'date_key': dateKey,
|
||||
@@ -92,7 +117,8 @@ class DayMenu {
|
||||
}
|
||||
|
||||
bool get hasAnyMeal => breakfast != null || lunch != null || dinner != null;
|
||||
int get mealCount => [breakfast, lunch, dinner].where((m) => m != null).length;
|
||||
int get mealCount =>
|
||||
[breakfast, lunch, dinner].where((m) => m != null).length;
|
||||
}
|
||||
|
||||
class MealItem {
|
||||
@@ -110,17 +136,30 @@ class MealItem {
|
||||
|
||||
factory MealItem.fromJson(Map<String, dynamic> json) {
|
||||
final ingredients = <ShoppingIngredient>[];
|
||||
if (json['ingredients'] != null) {
|
||||
ingredients.addAll(
|
||||
(json['ingredients'] as List)
|
||||
.map((e) => ShoppingIngredient.fromJson(e as Map<String, dynamic>))
|
||||
);
|
||||
try {
|
||||
if (json['ingredients'] != null && json['ingredients'] is List) {
|
||||
for (final e in (json['ingredients'] as List)) {
|
||||
if (e is Map) {
|
||||
try {
|
||||
ingredients.add(
|
||||
ShoppingIngredient.fromJson(Map<String, dynamic>.from(e)),
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('Failed to parse ShoppingIngredient: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Failed to parse ingredients: $e');
|
||||
}
|
||||
|
||||
return MealItem(
|
||||
recipeId: json['recipe_id'] ?? 0,
|
||||
recipeTitle: json['recipe_title'] ?? '',
|
||||
cover: json['cover'],
|
||||
recipeId: json['recipe_id'] is int
|
||||
? json['recipe_id'] as int
|
||||
: int.tryParse(json['recipe_id'].toString()) ?? 0,
|
||||
recipeTitle: json['recipe_title']?.toString() ?? '',
|
||||
cover: json['cover']?.toString(),
|
||||
ingredients: ingredients,
|
||||
);
|
||||
}
|
||||
@@ -158,12 +197,7 @@ class ShoppingIngredient {
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'name': name,
|
||||
'amount': amount,
|
||||
'unit': unit,
|
||||
'checked': checked,
|
||||
};
|
||||
return {'name': name, 'amount': amount, 'unit': unit, 'checked': checked};
|
||||
}
|
||||
|
||||
String get display => '$amount $unit $name';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// 2026-04-09 | recipe_detail_page.dart | 菜谱详情页 | 展示菜谱详细信息
|
||||
// 2026-04-11 | 重构: 拆分为Controller+18个独立UI组件,提高可维护性
|
||||
// 2026-04-13 | 新增rating数据传递到RecipeCoverImage和RecipeStatisticsBar
|
||||
// 2026-04-14 | 新增邮件分享按钮,支持发送菜谱详情到用户邮箱
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
@@ -25,6 +26,7 @@ import 'package:mom_kitchen/src/widgets/recipe_detail/interaction/recipe_tags_se
|
||||
import 'package:mom_kitchen/src/widgets/recipe_detail/header/recipe_time_info.dart';
|
||||
import 'package:mom_kitchen/src/widgets/recipe_detail/header/recipe_title_section.dart';
|
||||
import 'package:mom_kitchen/src/widgets/recipe_detail/info/recipe_similar_section.dart';
|
||||
import 'package:mom_kitchen/src/widgets/recipe_detail/interaction/recipe_email_button.dart';
|
||||
|
||||
class RecipeDetailPage extends StatelessWidget {
|
||||
final String recipeId;
|
||||
@@ -250,6 +252,8 @@ class RecipeDetailPage extends StatelessWidget {
|
||||
categoryId: recipe.categoryId,
|
||||
),
|
||||
),
|
||||
// 📧 邮件分享按钮
|
||||
RecipeEmailButton(recipe: recipe),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
530
lib/src/pages/profile/social/email_history_page.dart
Normal file
530
lib/src/pages/profile/social/email_history_page.dart
Normal file
@@ -0,0 +1,530 @@
|
||||
// 2026-04-15 | email_history_page.dart | 发件记录页面 | 展示邮件发送历史
|
||||
// 2026-04-15 | 初始创建,展示邮件发送记录列表,支持删除/清空/查看详情
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:mom_kitchen/src/config/app_routes.dart';
|
||||
import 'package:mom_kitchen/src/config/design_tokens.dart';
|
||||
import 'package:mom_kitchen/src/controllers/data/email_history_controller.dart';
|
||||
import 'package:mom_kitchen/src/models/data/email_record_model.dart';
|
||||
import 'package:mom_kitchen/src/services/ui/toast_service.dart';
|
||||
|
||||
class EmailHistoryPage extends StatelessWidget {
|
||||
const EmailHistoryPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark;
|
||||
// 确保控制器已注册
|
||||
final controller = Get.isRegistered<EmailHistoryController>()
|
||||
? Get.find<EmailHistoryController>()
|
||||
: Get.put(EmailHistoryController());
|
||||
|
||||
return CupertinoPageScaffold(
|
||||
backgroundColor:
|
||||
isDark ? DarkDesignTokens.background : DesignTokens.background,
|
||||
navigationBar: CupertinoNavigationBar(
|
||||
middle: Text(
|
||||
'📧 发件记录',
|
||||
style: TextStyle(
|
||||
color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1,
|
||||
fontSize: DesignTokens.fontMd,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
leading: CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
onPressed: () => Get.back(),
|
||||
child: Icon(
|
||||
CupertinoIcons.back,
|
||||
color: DesignTokens.dynamicPrimary,
|
||||
),
|
||||
),
|
||||
trailing: Obx(
|
||||
() => controller.records.isNotEmpty
|
||||
? CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
onPressed: () => _showClearDialog(context, controller, isDark),
|
||||
child: Text(
|
||||
'清空',
|
||||
style: TextStyle(
|
||||
color: DesignTokens.red,
|
||||
fontSize: DesignTokens.fontMd,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
backgroundColor: isDark
|
||||
? DarkDesignTokens.background.withValues(alpha: 0.9)
|
||||
: DesignTokens.background.withValues(alpha: 0.9),
|
||||
border: null,
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Obx(() => _buildBody(context, controller, isDark)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 页面主体
|
||||
Widget _buildBody(
|
||||
BuildContext context,
|
||||
EmailHistoryController controller,
|
||||
bool isDark,
|
||||
) {
|
||||
final records = controller.records;
|
||||
|
||||
if (records.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text('📧', style: TextStyle(fontSize: 56)),
|
||||
const SizedBox(height: DesignTokens.space4),
|
||||
Text(
|
||||
'还没有发送记录',
|
||||
style: TextStyle(
|
||||
color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2,
|
||||
fontSize: DesignTokens.fontLg,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: DesignTokens.space2),
|
||||
Text(
|
||||
'在菜谱详情页分享菜谱到邮箱',
|
||||
style: TextStyle(
|
||||
color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
|
||||
fontSize: DesignTokens.fontSm,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: DesignTokens.space4),
|
||||
CupertinoButton(
|
||||
onPressed: () => Get.toNamed('/'),
|
||||
child: Text(
|
||||
'去首页',
|
||||
style: TextStyle(color: DesignTokens.dynamicPrimary),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
_buildStatsHeader(controller, isDark),
|
||||
Expanded(
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.all(DesignTokens.space4),
|
||||
itemCount: records.length,
|
||||
separatorBuilder: (_, __) =>
|
||||
const SizedBox(height: DesignTokens.space3),
|
||||
itemBuilder: (context, index) =>
|
||||
_buildRecordCard(context, controller, records[index], isDark),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 统计头部
|
||||
Widget _buildStatsHeader(EmailHistoryController controller, bool isDark) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.fromLTRB(
|
||||
DesignTokens.space4,
|
||||
DesignTokens.space4,
|
||||
DesignTokens.space4,
|
||||
0,
|
||||
),
|
||||
padding: const EdgeInsets.all(DesignTokens.space4),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? DarkDesignTokens.card : DesignTokens.card,
|
||||
borderRadius: DesignTokens.borderRadiusMd,
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? DarkDesignTokens.glassBorder
|
||||
: DesignTokens.text3.withValues(alpha: 0.15),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text('📊', style: TextStyle(fontSize: 20)),
|
||||
const SizedBox(width: DesignTokens.space2),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'共 ${controller.records.length} 条记录',
|
||||
style: TextStyle(
|
||||
fontSize: DesignTokens.fontMd,
|
||||
color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1,
|
||||
),
|
||||
),
|
||||
),
|
||||
// 成功/失败标签
|
||||
if (controller.successCount > 0)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: DesignTokens.space2,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: DesignTokens.green.withValues(alpha: 0.1),
|
||||
borderRadius: DesignTokens.borderRadiusSm,
|
||||
),
|
||||
child: Text(
|
||||
'✅ ${controller.successCount}',
|
||||
style: TextStyle(
|
||||
fontSize: DesignTokens.fontXs,
|
||||
color: DesignTokens.green,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (controller.failedCount > 0) ...[
|
||||
const SizedBox(width: DesignTokens.space1),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: DesignTokens.space2,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: DesignTokens.red.withValues(alpha: 0.1),
|
||||
borderRadius: DesignTokens.borderRadiusSm,
|
||||
),
|
||||
child: Text(
|
||||
'❌ ${controller.failedCount}',
|
||||
style: TextStyle(
|
||||
fontSize: DesignTokens.fontXs,
|
||||
color: DesignTokens.red,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 单条记录卡片
|
||||
Widget _buildRecordCard(
|
||||
BuildContext context,
|
||||
EmailHistoryController controller,
|
||||
EmailRecordModel record,
|
||||
bool isDark,
|
||||
) {
|
||||
return Dismissible(
|
||||
key: ValueKey(record.id),
|
||||
direction: DismissDirection.endToStart,
|
||||
background: Container(
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: DesignTokens.space5),
|
||||
decoration: BoxDecoration(
|
||||
color: DesignTokens.red,
|
||||
borderRadius: DesignTokens.borderRadiusMd,
|
||||
),
|
||||
child: const Icon(CupertinoIcons.delete, color: CupertinoColors.white),
|
||||
),
|
||||
confirmDismiss: (_) => _confirmDelete(context, record, isDark),
|
||||
onDismissed: (_) {
|
||||
controller.removeRecord(record.id);
|
||||
ToastService.show(message: '记录已删除 🗑️');
|
||||
},
|
||||
child: GestureDetector(
|
||||
onTap: () => _showRecordDetail(context, record, isDark),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(DesignTokens.space3),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? DarkDesignTokens.card : DesignTokens.card,
|
||||
borderRadius: DesignTokens.borderRadiusMd,
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? DarkDesignTokens.glassBorder
|
||||
: DesignTokens.text3.withValues(alpha: 0.15),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 第一行:菜谱标题 + 状态
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'🍳 ${record.recipeTitle}',
|
||||
style: TextStyle(
|
||||
fontSize: DesignTokens.fontMd,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: isDark
|
||||
? DarkDesignTokens.text1
|
||||
: DesignTokens.text1,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: DesignTokens.space1,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: record.status == EmailSendStatus.success
|
||||
? DesignTokens.green.withValues(alpha: 0.1)
|
||||
: record.status == EmailSendStatus.failed
|
||||
? DesignTokens.red.withValues(alpha: 0.1)
|
||||
: DesignTokens.orange.withValues(alpha: 0.1),
|
||||
borderRadius: DesignTokens.borderRadiusSm,
|
||||
),
|
||||
child: Text(
|
||||
'${record.statusIcon} ${record.statusText}',
|
||||
style: TextStyle(
|
||||
fontSize: DesignTokens.fontXs,
|
||||
color: record.status == EmailSendStatus.success
|
||||
? DesignTokens.green
|
||||
: record.status == EmailSendStatus.failed
|
||||
? DesignTokens.red
|
||||
: DesignTokens.orange,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: DesignTokens.space2),
|
||||
// 第二行:收件人
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
CupertinoIcons.mail,
|
||||
size: 12,
|
||||
color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
|
||||
),
|
||||
const SizedBox(width: DesignTokens.space1),
|
||||
Expanded(
|
||||
child: Text(
|
||||
record.recipientEmail,
|
||||
style: TextStyle(
|
||||
fontSize: DesignTokens.fontSm,
|
||||
color: isDark
|
||||
? DarkDesignTokens.text2
|
||||
: DesignTokens.text2,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: DesignTokens.space1),
|
||||
// 第三行:线路 + 时间
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
CupertinoIcons.antenna_radiowaves_left_right,
|
||||
size: 12,
|
||||
color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
|
||||
),
|
||||
const SizedBox(width: DesignTokens.space1),
|
||||
Text(
|
||||
record.routeName,
|
||||
style: TextStyle(
|
||||
fontSize: DesignTokens.fontXs,
|
||||
color: isDark
|
||||
? DarkDesignTokens.text3
|
||||
: DesignTokens.text3,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: DesignTokens.space2),
|
||||
Icon(
|
||||
CupertinoIcons.clock,
|
||||
size: 12,
|
||||
color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
|
||||
),
|
||||
const SizedBox(width: DesignTokens.space1),
|
||||
Text(
|
||||
record.displayDate,
|
||||
style: TextStyle(
|
||||
fontSize: DesignTokens.fontXs,
|
||||
color: isDark
|
||||
? DarkDesignTokens.text3
|
||||
: DesignTokens.text3,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// 错误信息
|
||||
if (record.errorMessage != null &&
|
||||
record.errorMessage!.isNotEmpty) ...[
|
||||
const SizedBox(height: DesignTokens.space2),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(DesignTokens.space2),
|
||||
decoration: BoxDecoration(
|
||||
color: DesignTokens.red.withValues(alpha: 0.05),
|
||||
borderRadius: DesignTokens.borderRadiusSm,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
CupertinoIcons.exclamationmark_triangle,
|
||||
size: 12,
|
||||
color: DesignTokens.red,
|
||||
),
|
||||
const SizedBox(width: DesignTokens.space1),
|
||||
Expanded(
|
||||
child: Text(
|
||||
record.errorMessage!,
|
||||
style: const TextStyle(
|
||||
fontSize: DesignTokens.fontXs,
|
||||
color: DesignTokens.red,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 记录详情弹窗
|
||||
void _showRecordDetail(
|
||||
BuildContext context,
|
||||
EmailRecordModel record,
|
||||
bool isDark,
|
||||
) {
|
||||
showCupertinoDialog(
|
||||
context: context,
|
||||
builder: (ctx) => CupertinoAlertDialog(
|
||||
title: Text('${record.statusIcon} ${record.recipeTitle}'),
|
||||
content: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: DesignTokens.space3),
|
||||
_detailRow('📧 收件人', record.recipientEmail, isDark),
|
||||
_detailRow('📤 发件人', record.senderEmail, isDark),
|
||||
_detailRow('🌐 服务器', '${record.smtpHost}:${record.smtpPort}', isDark),
|
||||
_detailRow('🔀 线路', record.routeName, isDark),
|
||||
_detailRow('📝 主题', record.subject, isDark),
|
||||
_detailRow('📊 状态', record.statusText, isDark),
|
||||
_detailRow('🕐 时间', record.displayDate, isDark),
|
||||
if (record.errorMessage != null &&
|
||||
record.errorMessage!.isNotEmpty)
|
||||
_detailRow('⚠️ 错误', record.errorMessage!, isDark),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
// 跳转到菜谱详情
|
||||
CupertinoDialogAction(
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
Get.toNamed(
|
||||
AppRoutes.recipeDetail,
|
||||
arguments: record.recipeId,
|
||||
);
|
||||
},
|
||||
child: Text(
|
||||
'查看菜谱',
|
||||
style: TextStyle(color: DesignTokens.dynamicPrimary),
|
||||
),
|
||||
),
|
||||
CupertinoDialogAction(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('关闭'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 详情行
|
||||
Widget _detailRow(String label, String value, bool isDark) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: DesignTokens.space1),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 80,
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: DesignTokens.fontSm,
|
||||
color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: DesignTokens.fontSm,
|
||||
color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 删除确认
|
||||
Future<bool> _confirmDelete(
|
||||
BuildContext context,
|
||||
EmailRecordModel record,
|
||||
bool isDark,
|
||||
) async {
|
||||
return await showCupertinoDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => CupertinoAlertDialog(
|
||||
title: const Text('删除记录'),
|
||||
content: Text('确定要删除「${record.recipeTitle}」的发件记录吗?'),
|
||||
actions: [
|
||||
CupertinoDialogAction(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
CupertinoDialogAction(
|
||||
isDestructiveAction: true,
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('删除'),
|
||||
),
|
||||
],
|
||||
),
|
||||
) ??
|
||||
false;
|
||||
}
|
||||
|
||||
/// 清空确认
|
||||
void _showClearDialog(
|
||||
BuildContext context,
|
||||
EmailHistoryController controller,
|
||||
bool isDark,
|
||||
) {
|
||||
showCupertinoDialog(
|
||||
context: context,
|
||||
builder: (ctx) => CupertinoAlertDialog(
|
||||
title: const Text('清空发件记录'),
|
||||
content: const Text('确定要清空所有发件记录吗?此操作不可撤销。'),
|
||||
actions: [
|
||||
CupertinoDialogAction(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
CupertinoDialogAction(
|
||||
isDestructiveAction: true,
|
||||
onPressed: () {
|
||||
controller.clearRecords();
|
||||
Navigator.pop(ctx);
|
||||
ToastService.show(message: '发件记录已清空 🗑️');
|
||||
},
|
||||
child: const Text('清空'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
* 说明: 浏览记录页面,展示用户浏览菜谱历史
|
||||
* 作用: 展示用户足迹,支持管理、删除、清空
|
||||
* 更新时间: 2026-04-12 重构为真实浏览记录功能,使用SharedPreferences存储
|
||||
* 更新时间: 2026-04-15 统计栏新增发件记录入口
|
||||
*/
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
@@ -10,6 +11,7 @@ import 'package:get/get.dart';
|
||||
import 'package:mom_kitchen/src/config/app_routes.dart';
|
||||
import 'package:mom_kitchen/src/config/design_tokens.dart';
|
||||
import 'package:mom_kitchen/src/controllers/data/browse_history_controller.dart';
|
||||
import 'package:mom_kitchen/src/controllers/data/email_history_controller.dart';
|
||||
import 'package:mom_kitchen/src/models/data/browse_history_model.dart';
|
||||
import 'package:mom_kitchen/src/pages/profile/data/cache_manage_page.dart';
|
||||
import 'package:mom_kitchen/src/services/ui/toast_service.dart';
|
||||
@@ -175,6 +177,42 @@ class FootprintsPage extends StatelessWidget {
|
||||
color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
// 📧 发件记录入口
|
||||
CupertinoButton(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: DesignTokens.space2,
|
||||
vertical: DesignTokens.space1,
|
||||
),
|
||||
minSize: 0,
|
||||
onPressed: () {
|
||||
// 确保控制器已注册
|
||||
if (!Get.isRegistered<EmailHistoryController>()) {
|
||||
Get.put(EmailHistoryController());
|
||||
}
|
||||
Get.toNamed('/email-history');
|
||||
},
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('📧', style: TextStyle(fontSize: 14)),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
'发件记录',
|
||||
style: TextStyle(
|
||||
fontSize: DesignTokens.fontSm,
|
||||
color: DesignTokens.dynamicPrimary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
CupertinoIcons.right_chevron,
|
||||
size: 12,
|
||||
color: DesignTokens.dynamicPrimary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:mom_kitchen/src/config/design_tokens.dart';
|
||||
|
||||
601
lib/src/services/crash_guard_service.dart
Normal file
601
lib/src/services/crash_guard_service.dart
Normal file
@@ -0,0 +1,601 @@
|
||||
/*
|
||||
* 文件: crash_guard_service.dart
|
||||
* 名称: 全局闪退守护服务(catcher_2 集成)
|
||||
* 作用: 基于 catcher_2 捕获全局未处理异常,弹出 iOS 风格错误报告对话框
|
||||
* 创建: 2026-04-15
|
||||
* 更新: 2026-04-15 修正初始化时序、ReportMode context处理
|
||||
*/
|
||||
|
||||
import 'dart:io';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart' show Colors, SelectableText, showDialog;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:catcher_2/catcher_2.dart';
|
||||
import 'package:catcher_2/model/platform_type.dart';
|
||||
import 'package:mom_kitchen/src/services/ui/theme_service.dart';
|
||||
|
||||
class CrashGuardService extends GetxService {
|
||||
static CrashGuardService get instance => Get.find<CrashGuardService>();
|
||||
|
||||
final RxList<CrashReportEntry> crashHistory = <CrashReportEntry>[].obs;
|
||||
|
||||
static Future<CrashGuardService> init() async {
|
||||
final service = CrashGuardService();
|
||||
Get.put(service, permanent: true);
|
||||
return service;
|
||||
}
|
||||
|
||||
Catcher2Options buildDebugOptions() {
|
||||
return Catcher2Options(
|
||||
_CupertinoDialogReportMode(),
|
||||
[
|
||||
ConsoleHandler(
|
||||
enableApplicationParameters: true,
|
||||
enableDeviceParameters: true,
|
||||
enableCustomParameters: true,
|
||||
),
|
||||
],
|
||||
localizationOptions: [
|
||||
LocalizationOptions(
|
||||
'zh',
|
||||
dialogReportModeTitle: '🐛 发现异常',
|
||||
dialogReportModeDescription: '应用遇到了一个错误,你可以复制错误信息反馈给开发者。',
|
||||
dialogReportModeAccept: '复制并关闭',
|
||||
dialogReportModeCancel: '忽略',
|
||||
pageReportModeTitle: '错误报告',
|
||||
pageReportModeDescription: '应用遇到了一个错误,详情如下:',
|
||||
pageReportModeAccept: '确认',
|
||||
pageReportModeCancel: '取消',
|
||||
toastHandlerDescription: '发生错误',
|
||||
notificationReportModeTitle: '异常通知',
|
||||
notificationReportModeContent: '应用遇到了一个错误',
|
||||
),
|
||||
],
|
||||
reportOccurrenceTimeout: 3000,
|
||||
);
|
||||
}
|
||||
|
||||
Catcher2Options buildReleaseOptions() {
|
||||
return Catcher2Options(
|
||||
_CupertinoDialogReportMode(),
|
||||
[
|
||||
ConsoleHandler(
|
||||
enableApplicationParameters: true,
|
||||
enableDeviceParameters: true,
|
||||
),
|
||||
],
|
||||
localizationOptions: [
|
||||
LocalizationOptions(
|
||||
'zh',
|
||||
dialogReportModeTitle: '⚠️ 出错了',
|
||||
dialogReportModeDescription: '应用遇到了一个错误,请复制错误信息反馈给我们以帮助改进。',
|
||||
dialogReportModeAccept: '复制报告',
|
||||
dialogReportModeCancel: '关闭',
|
||||
pageReportModeTitle: '错误报告',
|
||||
pageReportModeDescription: '应用遇到了一个错误,详情如下:',
|
||||
pageReportModeAccept: '确认',
|
||||
pageReportModeCancel: '取消',
|
||||
toastHandlerDescription: '发生错误',
|
||||
notificationReportModeTitle: '异常通知',
|
||||
notificationReportModeContent: '应用遇到了一个错误',
|
||||
),
|
||||
],
|
||||
reportOccurrenceTimeout: 5000,
|
||||
);
|
||||
}
|
||||
|
||||
void recordCrash(Report report) {
|
||||
final entry = CrashReportEntry(
|
||||
error: report.error.toString(),
|
||||
stackTrace: report.stackTrace.toString(),
|
||||
timestamp: DateTime.now(),
|
||||
deviceParameters: report.deviceParameters,
|
||||
applicationParameters: report.applicationParameters,
|
||||
);
|
||||
crashHistory.add(entry);
|
||||
debugPrint('🐛 [CrashGuard] 捕获到错误: ${entry.shortError}');
|
||||
}
|
||||
|
||||
void sendTestException() {
|
||||
Catcher2.sendTestException();
|
||||
}
|
||||
|
||||
void reportCheckedError(Object error, StackTrace stackTrace) {
|
||||
Catcher2.reportCheckedError(error, stackTrace);
|
||||
}
|
||||
|
||||
void clearHistory() {
|
||||
crashHistory.clear();
|
||||
}
|
||||
}
|
||||
|
||||
class CrashReportEntry {
|
||||
final String error;
|
||||
final String stackTrace;
|
||||
final DateTime timestamp;
|
||||
final Map<String, dynamic>? deviceParameters;
|
||||
final Map<String, dynamic>? applicationParameters;
|
||||
|
||||
const CrashReportEntry({
|
||||
required this.error,
|
||||
required this.stackTrace,
|
||||
required this.timestamp,
|
||||
this.deviceParameters,
|
||||
this.applicationParameters,
|
||||
});
|
||||
|
||||
String get shortError {
|
||||
if (error.length > 200) return '${error.substring(0, 200)}...';
|
||||
return error;
|
||||
}
|
||||
|
||||
String get fullReport {
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln('=== 🐛 小妈厨房 错误报告 ===');
|
||||
buffer.writeln('⏰ 时间: ${timestamp.toString().substring(0, 19)}');
|
||||
buffer.writeln('📱 设备: ${Platform.operatingSystem}');
|
||||
buffer.writeln();
|
||||
buffer.writeln('💬 错误详情:');
|
||||
buffer.writeln(error);
|
||||
buffer.writeln();
|
||||
buffer.writeln('📋 堆栈跟踪:');
|
||||
buffer.writeln(stackTrace);
|
||||
if (deviceParameters != null && deviceParameters!.isNotEmpty) {
|
||||
buffer.writeln();
|
||||
buffer.writeln('📟 设备信息:');
|
||||
deviceParameters!.forEach((k, v) => buffer.writeln(' $k: $v'));
|
||||
}
|
||||
if (applicationParameters != null && applicationParameters!.isNotEmpty) {
|
||||
buffer.writeln();
|
||||
buffer.writeln('📦 应用信息:');
|
||||
applicationParameters!.forEach((k, v) => buffer.writeln(' $k: $v'));
|
||||
}
|
||||
buffer.writeln('================================');
|
||||
return buffer.toString();
|
||||
}
|
||||
}
|
||||
|
||||
class _CupertinoDialogReportMode extends ReportMode {
|
||||
@override
|
||||
void requestAction(Report report, BuildContext? context) {
|
||||
if (context != null && context.mounted) {
|
||||
_showCupertinoCrashDialog(report, context);
|
||||
return;
|
||||
}
|
||||
|
||||
final navContext = Catcher2.navigatorKey.currentContext;
|
||||
if (navContext != null && navContext.mounted) {
|
||||
_showCupertinoCrashDialog(report, navContext);
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('🐛 [CrashGuard] 无法获取context,延迟重试显示弹窗');
|
||||
_retryShowDialog(report);
|
||||
}
|
||||
|
||||
void _retryShowDialog(Report report) {
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
final navContext = Catcher2.navigatorKey.currentContext;
|
||||
if (navContext != null && navContext.mounted) {
|
||||
_showCupertinoCrashDialog(report, navContext);
|
||||
} else {
|
||||
debugPrint('🐛 [CrashGuard] 重试失败,放弃显示弹窗,记录到控制台');
|
||||
debugPrint('🐛 错误: ${report.error}');
|
||||
debugPrint('🐛 堆栈: ${report.stackTrace}');
|
||||
onActionConfirmed(report);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
bool isContextRequired() => true;
|
||||
|
||||
@override
|
||||
List<PlatformType> getSupportedPlatforms() => [
|
||||
PlatformType.android,
|
||||
PlatformType.iOS,
|
||||
PlatformType.web,
|
||||
PlatformType.macOS,
|
||||
PlatformType.linux,
|
||||
PlatformType.windows,
|
||||
];
|
||||
|
||||
void _showCupertinoCrashDialog(Report report, BuildContext context) {
|
||||
final isDark = _isDarkMode();
|
||||
|
||||
try {
|
||||
CrashGuardService.instance.recordCrash(report);
|
||||
} catch (_) {}
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
barrierColor: const Color(0x88000000),
|
||||
builder: (dialogContext) =>
|
||||
_CupertinoCrashDialog(report: report, isDark: isDark),
|
||||
)
|
||||
.then((result) {
|
||||
if (result == true) {
|
||||
_copyReportToClipboard(report);
|
||||
HapticFeedback.lightImpact();
|
||||
}
|
||||
onActionConfirmed(report);
|
||||
})
|
||||
.catchError((_) {
|
||||
onActionRejected(report);
|
||||
});
|
||||
}
|
||||
|
||||
bool _isDarkMode() {
|
||||
try {
|
||||
final themeService = Get.find<ThemeService>();
|
||||
return themeService.isDarkMode.value;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void _copyReportToClipboard(Report report) {
|
||||
final entry = CrashReportEntry(
|
||||
error: report.error.toString(),
|
||||
stackTrace: report.stackTrace.toString(),
|
||||
timestamp: DateTime.now(),
|
||||
);
|
||||
Clipboard.setData(ClipboardData(text: entry.fullReport));
|
||||
}
|
||||
}
|
||||
|
||||
class _CupertinoCrashDialog extends StatefulWidget {
|
||||
final Report report;
|
||||
final bool isDark;
|
||||
|
||||
const _CupertinoCrashDialog({required this.report, required this.isDark});
|
||||
|
||||
@override
|
||||
State<_CupertinoCrashDialog> createState() => _CupertinoCrashDialogState();
|
||||
}
|
||||
|
||||
class _CupertinoCrashDialogState extends State<_CupertinoCrashDialog> {
|
||||
bool _showStackTrace = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = widget.isDark;
|
||||
final bgColor = isDark ? const Color(0xFF1C1C1E) : CupertinoColors.white;
|
||||
final textColor = isDark ? CupertinoColors.white : CupertinoColors.black;
|
||||
final secondaryBg = isDark
|
||||
? const Color(0xFF2C2C2E)
|
||||
: const Color(0xFFF2F2F7);
|
||||
final primaryColor = CupertinoColors.activeBlue;
|
||||
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
child: Center(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 28),
|
||||
constraints: const BoxConstraints(maxWidth: 420, maxHeight: 560),
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.25),
|
||||
blurRadius: 24,
|
||||
offset: const Offset(0, 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildHeader(textColor, secondaryBg),
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildDeviceInfo(secondaryBg, textColor),
|
||||
const SizedBox(height: 14),
|
||||
_buildErrorMessage(secondaryBg, textColor),
|
||||
if (_showStackTrace) ...[
|
||||
const SizedBox(height: 14),
|
||||
_buildStackTrace(secondaryBg, textColor),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildActions(textColor, primaryColor),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(Color textColor, Color secondaryBg) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.fromLTRB(20, 18, 20, 14),
|
||||
decoration: BoxDecoration(
|
||||
color: secondaryBg,
|
||||
border: Border(
|
||||
bottom: BorderSide(color: textColor.withValues(alpha: 0.08)),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: CupertinoColors.systemRed.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Icon(
|
||||
CupertinoIcons.exclamationmark_triangle_fill,
|
||||
color: CupertinoColors.systemRed,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'🐛 发现异常',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: textColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
_getErrorCategory(),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: textColor.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getErrorCategory() {
|
||||
final errorStr = widget.report.error.toString().toLowerCase();
|
||||
if (errorStr.contains('type') && errorStr.contains('not a subtype')) {
|
||||
return '类型转换错误';
|
||||
}
|
||||
if (errorStr.contains('null') || errorStr.contains('nosuchmethod')) {
|
||||
return '空指针异常';
|
||||
}
|
||||
if (errorStr.contains('network') || errorStr.contains('socket')) {
|
||||
return '网络错误';
|
||||
}
|
||||
if (errorStr.contains('timeout')) {
|
||||
return '请求超时';
|
||||
}
|
||||
if (errorStr.contains('renderflex') || errorStr.contains('overflowed')) {
|
||||
return '布局溢出';
|
||||
}
|
||||
if (errorStr.contains('hive') || errorStr.contains('database')) {
|
||||
return '数据存储错误';
|
||||
}
|
||||
return '运行时错误';
|
||||
}
|
||||
|
||||
Widget _buildDeviceInfo(Color secondaryBg, Color textColor) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: secondaryBg,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
CupertinoIcons.device_phone_portrait,
|
||||
size: 13,
|
||||
color: textColor.withValues(alpha: 0.45),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${Platform.operatingSystem} · Flutter',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: textColor.withValues(alpha: 0.55),
|
||||
),
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _showStackTrace = !_showStackTrace),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: CupertinoColors.activeBlue.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
_showStackTrace
|
||||
? CupertinoIcons.chevron_up
|
||||
: CupertinoIcons.chevron_down,
|
||||
size: 11,
|
||||
color: CupertinoColors.activeBlue,
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
'堆栈',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: CupertinoColors.activeBlue,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorMessage(Color secondaryBg, Color textColor) {
|
||||
final message = widget.report.error.toString();
|
||||
final displayMessage = message.length > 300
|
||||
? '${message.substring(0, 300)}...'
|
||||
: message;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'错误详情',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: textColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: secondaryBg,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: textColor.withValues(alpha: 0.06)),
|
||||
),
|
||||
child: SelectableText(
|
||||
displayMessage,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
height: 1.5,
|
||||
color: textColor.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStackTrace(Color secondaryBg, Color textColor) {
|
||||
final trace = widget.report.stackTrace.toString();
|
||||
final displayTrace = trace.length > 600
|
||||
? '${trace.substring(0, 600)}\n...(已截断)'
|
||||
: trace;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'堆栈跟踪',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: textColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
constraints: const BoxConstraints(maxHeight: 140),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: secondaryBg,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: SelectableText(
|
||||
displayTrace,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontFamily: 'monospace',
|
||||
height: 1.4,
|
||||
color: textColor.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActions(Color textColor, Color primaryColor) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.fromLTRB(20, 4, 20, 18),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(color: textColor.withValues(alpha: 0.06)),
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: CupertinoButton(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
color: textColor.withValues(alpha: 0.06),
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text(
|
||||
'忽略',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: textColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: CupertinoButton(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
color: primaryColor,
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
CupertinoIcons.doc_on_clipboard_fill,
|
||||
size: 16,
|
||||
color: CupertinoColors.white,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
const Text(
|
||||
'复制并关闭',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: CupertinoColors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
365
lib/src/services/data/email_service.dart
Normal file
365
lib/src/services/data/email_service.dart
Normal file
@@ -0,0 +1,365 @@
|
||||
// 2026-04-14 | email_service.dart | 邮件发送服务 | 通过SMTP发送菜谱详情到用户邮箱
|
||||
// 2026-04-14 | 初始创建,基于mailer库实现SMTP邮件发送
|
||||
// 2026-04-14 | 新增多线路支持:官方线路1(mboxhosting)/线路2(QQ邮箱)/自定义SMTP
|
||||
// 2026-04-15 | 发送成功/失败后自动记录到 EmailHistoryController
|
||||
|
||||
import 'dart:io';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:mom_kitchen/src/models/recipe/recipe_model.dart';
|
||||
import 'package:mom_kitchen/src/models/data/email_record_model.dart';
|
||||
import 'package:mom_kitchen/src/controllers/data/email_history_controller.dart';
|
||||
import 'package:mom_kitchen/src/services/log/logger_service.dart';
|
||||
import 'package:mailer/mailer.dart';
|
||||
import 'package:mailer/smtp_server.dart';
|
||||
|
||||
/// SMTP 线路配置
|
||||
class SmtpRoute {
|
||||
final String name;
|
||||
final String host;
|
||||
final int port;
|
||||
final bool ssl;
|
||||
final String username;
|
||||
final String password;
|
||||
final String icon;
|
||||
|
||||
const SmtpRoute({
|
||||
required this.name,
|
||||
required this.host,
|
||||
required this.port,
|
||||
required this.ssl,
|
||||
required this.username,
|
||||
required this.password,
|
||||
required this.icon,
|
||||
});
|
||||
}
|
||||
|
||||
/// 邮件发送服务
|
||||
/// 使用 SMTP 协议发送菜谱详情邮件,支持多线路切换
|
||||
class EmailService {
|
||||
EmailService._internal();
|
||||
static final EmailService _instance = EmailService._internal();
|
||||
factory EmailService() => _instance;
|
||||
|
||||
/// 预设 SMTP 线路
|
||||
static const List<SmtpRoute> presetRoutes = [
|
||||
SmtpRoute(
|
||||
name: '官方线路1',
|
||||
icon: '🚀',
|
||||
host: 'free.mboxhosting.com',
|
||||
port: 465,
|
||||
ssl: true,
|
||||
username: 'gg@0gg.cc',
|
||||
password: '520kiss123',
|
||||
),
|
||||
SmtpRoute(
|
||||
name: '官方线路2',
|
||||
icon: '✉️',
|
||||
host: 'smtp.qq.com',
|
||||
port: 465,
|
||||
ssl: true,
|
||||
username: '2821981550@qq.com',
|
||||
password: '520kiss123',
|
||||
),
|
||||
];
|
||||
|
||||
/// 发送菜谱详情邮件(使用预设线路)
|
||||
/// [recipientEmail] 收件人邮箱
|
||||
/// [recipe] 菜谱数据
|
||||
/// [routeIndex] 预设线路索引 (0=线路1, 1=线路2)
|
||||
static Future<bool> sendRecipeEmail({
|
||||
required String recipientEmail,
|
||||
required RecipeModel recipe,
|
||||
int routeIndex = 0,
|
||||
}) async {
|
||||
final route = presetRoutes[routeIndex];
|
||||
return _send(
|
||||
recipientEmail: recipientEmail,
|
||||
recipe: recipe,
|
||||
host: route.host,
|
||||
port: route.port,
|
||||
ssl: route.ssl,
|
||||
username: route.username,
|
||||
password: route.password,
|
||||
routeName: route.name,
|
||||
);
|
||||
}
|
||||
|
||||
/// 发送菜谱详情邮件(使用自定义SMTP)
|
||||
/// [recipientEmail] 收件人邮箱
|
||||
/// [recipe] 菜谱数据
|
||||
/// [smtpHost] SMTP服务器地址
|
||||
/// [smtpPort] SMTP端口
|
||||
/// [smtpUsername] SMTP用户名
|
||||
/// [smtpPassword] SMTP密码/授权码
|
||||
static Future<bool> sendRecipeEmailCustom({
|
||||
required String recipientEmail,
|
||||
required RecipeModel recipe,
|
||||
required String smtpHost,
|
||||
required int smtpPort,
|
||||
required String smtpUsername,
|
||||
required String smtpPassword,
|
||||
}) async {
|
||||
return _send(
|
||||
recipientEmail: recipientEmail,
|
||||
recipe: recipe,
|
||||
host: smtpHost,
|
||||
port: smtpPort,
|
||||
ssl: smtpPort == 465,
|
||||
username: smtpUsername,
|
||||
password: smtpPassword,
|
||||
routeName: '自定义($smtpHost)',
|
||||
);
|
||||
}
|
||||
|
||||
/// 核心发送方法
|
||||
static Future<bool> _send({
|
||||
required String recipientEmail,
|
||||
required RecipeModel recipe,
|
||||
required String host,
|
||||
required int port,
|
||||
required bool ssl,
|
||||
required String username,
|
||||
required String password,
|
||||
required String routeName,
|
||||
}) async {
|
||||
// 确保控制器已注册
|
||||
if (!Get.isRegistered<EmailHistoryController>()) {
|
||||
Get.put(EmailHistoryController());
|
||||
}
|
||||
final historyCtrl = Get.find<EmailHistoryController>();
|
||||
|
||||
// 创建发送记录
|
||||
final record = EmailRecordModel(
|
||||
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
recipeId: recipe.id.toString(),
|
||||
recipeTitle: recipe.title,
|
||||
recipientEmail: recipientEmail,
|
||||
senderEmail: username,
|
||||
smtpHost: host,
|
||||
smtpPort: port,
|
||||
routeName: routeName,
|
||||
subject: '🍳 ${recipe.title} — 小妈厨房菜谱分享',
|
||||
status: EmailSendStatus.sending,
|
||||
sentAt: DateTime.now().toIso8601String(),
|
||||
);
|
||||
|
||||
// 先记录"发送中"状态
|
||||
await historyCtrl.addRecord(record);
|
||||
|
||||
try {
|
||||
final smtpServer = SmtpServer(
|
||||
host,
|
||||
port: port,
|
||||
ssl: ssl,
|
||||
allowInsecure: !ssl,
|
||||
username: username,
|
||||
password: password,
|
||||
);
|
||||
|
||||
final message = Message()
|
||||
..from = Address(username, '小妈厨房')
|
||||
..recipients.add(recipientEmail)
|
||||
..subject = '🍳 ${recipe.title} — 小妈厨房菜谱分享'
|
||||
..text = _buildPlainText(recipe)
|
||||
..html = _buildHtmlContent(recipe);
|
||||
|
||||
final sendReport = await send(message, smtpServer);
|
||||
|
||||
// 更新为成功状态
|
||||
await historyCtrl.updateRecordStatus(record.id, EmailSendStatus.success);
|
||||
LoggerService().info('[$routeName] 邮件发送成功: ${sendReport.toString()}');
|
||||
return true;
|
||||
} on MailerException catch (e) {
|
||||
final problems = e.problems.map((p) => '${p.code}: ${p.msg}').join(', ');
|
||||
await historyCtrl.updateRecordStatus(
|
||||
record.id,
|
||||
EmailSendStatus.failed,
|
||||
errorMessage: problems,
|
||||
);
|
||||
LoggerService().error('[$routeName] 邮件发送失败: $problems', e);
|
||||
return false;
|
||||
} on SocketException catch (e) {
|
||||
await historyCtrl.updateRecordStatus(
|
||||
record.id,
|
||||
EmailSendStatus.failed,
|
||||
errorMessage: '网络连接失败: ${e.message}',
|
||||
);
|
||||
LoggerService().error('[$routeName] 网络连接失败: ${e.message}', e);
|
||||
return false;
|
||||
} catch (e) {
|
||||
await historyCtrl.updateRecordStatus(
|
||||
record.id,
|
||||
EmailSendStatus.failed,
|
||||
errorMessage: e.toString(),
|
||||
);
|
||||
LoggerService().error('[$routeName] 邮件发送异常: $e', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成纯文本邮件内容
|
||||
static String _buildPlainText(RecipeModel recipe) {
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln('🍳 ${recipe.title}');
|
||||
buffer.writeln('═' * 40);
|
||||
if (recipe.intro != null && recipe.intro!.isNotEmpty) {
|
||||
buffer.writeln();
|
||||
buffer.writeln('📝 简介:${recipe.intro}');
|
||||
}
|
||||
if (recipe.categoryName != null) {
|
||||
buffer.writeln('📂 分类:${recipe.categoryName}');
|
||||
}
|
||||
buffer.writeln();
|
||||
|
||||
// 食材列表
|
||||
if (recipe.ingredients.isNotEmpty) {
|
||||
buffer.writeln('🥘 食材');
|
||||
buffer.writeln('─' * 20);
|
||||
for (final ing in recipe.ingredients) {
|
||||
buffer.writeln(' • ${ing.name} ${ing.amount ?? ''} ${ing.unit ?? ''}');
|
||||
}
|
||||
buffer.writeln();
|
||||
}
|
||||
|
||||
// 步骤
|
||||
if (recipe.content != null && recipe.content!.isNotEmpty) {
|
||||
buffer.writeln('👨🍳 步骤');
|
||||
buffer.writeln('─' * 20);
|
||||
final steps =
|
||||
recipe.content!.split(RegExp(r'\n+')).where((s) => s.trim().isNotEmpty);
|
||||
var stepNum = 1;
|
||||
for (final step in steps) {
|
||||
buffer.writeln(' $stepNum. ${step.trim()}');
|
||||
stepNum++;
|
||||
}
|
||||
buffer.writeln();
|
||||
}
|
||||
|
||||
// 营养信息
|
||||
if (recipe.nutrition != null) {
|
||||
buffer.writeln('💪 营养信息');
|
||||
buffer.writeln('─' * 20);
|
||||
final n = recipe.nutrition!;
|
||||
if (n.calories != null) buffer.writeln(' 🔥 热量:${n.calories}');
|
||||
if (n.protein != null) buffer.writeln(' 💪 蛋白质:${n.protein}');
|
||||
if (n.fat != null) buffer.writeln(' 🧈 脂肪:${n.fat}');
|
||||
if (n.carbs != null) buffer.writeln(' 🍞 碳水:${n.carbs}');
|
||||
if (n.fiber != null) buffer.writeln(' 🌾 膳食纤维:${n.fiber}');
|
||||
}
|
||||
|
||||
buffer.writeln();
|
||||
buffer.writeln('— 来自小妈厨房 🏠');
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
/// 生成HTML邮件内容
|
||||
static String _buildHtmlContent(RecipeModel recipe) {
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln('<!DOCTYPE html>');
|
||||
buffer.writeln('<html><head><meta charset="utf-8">');
|
||||
buffer.writeln('<style>');
|
||||
buffer.writeln('body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; '
|
||||
'max-width: 600px; margin: 0 auto; padding: 20px; '
|
||||
'background: #F2F2F7; color: #1C1C1E; }');
|
||||
buffer.writeln('.header { background: linear-gradient(135deg, #007AFF, #5856D6); '
|
||||
'color: white; padding: 24px; border-radius: 12px; margin-bottom: 20px; }');
|
||||
buffer.writeln('.header h1 { margin: 0 0 8px; font-size: 24px; }');
|
||||
buffer.writeln('.header p { margin: 0; opacity: 0.9; font-size: 14px; }');
|
||||
buffer.writeln('.section { background: white; padding: 16px; border-radius: 12px; '
|
||||
'margin-bottom: 12px; }');
|
||||
buffer.writeln('.section h2 { margin: 0 0 12px; font-size: 16px; color: #007AFF; }');
|
||||
buffer.writeln('.ingredient { padding: 6px 0; border-bottom: 1px solid #F2F2F7; '
|
||||
'display: flex; justify-content: space-between; }');
|
||||
buffer.writeln('.ingredient:last-child { border-bottom: none; }');
|
||||
buffer.writeln('.step { padding: 8px 0; border-bottom: 1px solid #F2F2F7; }');
|
||||
buffer.writeln('.step:last-child { border-bottom: none; }');
|
||||
buffer.writeln('.step-num { display: inline-block; background: #007AFF; color: white; '
|
||||
'width: 24px; height: 24px; text-align: center; line-height: 24px; '
|
||||
'border-radius: 50%; font-size: 12px; margin-right: 8px; }');
|
||||
buffer.writeln('.nutrition-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }');
|
||||
buffer.writeln('.nutrition-item { background: #F2F2F7; padding: 8px 12px; border-radius: 8px; }');
|
||||
buffer.writeln('.nutrition-label { font-size: 12px; color: #8E8E93; }');
|
||||
buffer.writeln('.nutrition-value { font-size: 16px; font-weight: 600; }');
|
||||
buffer.writeln('.footer { text-align: center; padding: 16px; color: #8E8E93; font-size: 12px; }');
|
||||
buffer.writeln('</style></head><body>');
|
||||
|
||||
// 头部
|
||||
buffer.writeln('<div class="header">');
|
||||
buffer.writeln('<h1>🍳 ${recipe.title}</h1>');
|
||||
if (recipe.intro != null && recipe.intro!.isNotEmpty) {
|
||||
buffer.writeln('<p>${recipe.intro}</p>');
|
||||
}
|
||||
if (recipe.categoryName != null) {
|
||||
buffer.writeln('<p>📂 ${recipe.categoryName}</p>');
|
||||
}
|
||||
buffer.writeln('</div>');
|
||||
|
||||
// 食材
|
||||
if (recipe.ingredients.isNotEmpty) {
|
||||
buffer.writeln('<div class="section">');
|
||||
buffer.writeln('<h2>🥘 食材</h2>');
|
||||
for (final ing in recipe.ingredients) {
|
||||
buffer.writeln('<div class="ingredient">'
|
||||
'<span>${ing.name}</span>'
|
||||
'<span>${ing.amount ?? ''} ${ing.unit ?? ''}</span>'
|
||||
'</div>');
|
||||
}
|
||||
buffer.writeln('</div>');
|
||||
}
|
||||
|
||||
// 步骤
|
||||
if (recipe.content != null && recipe.content!.isNotEmpty) {
|
||||
buffer.writeln('<div class="section">');
|
||||
buffer.writeln('<h2>👨🍳 步骤</h2>');
|
||||
final steps = recipe.content!
|
||||
.split(RegExp(r'\n+'))
|
||||
.where((s) => s.trim().isNotEmpty);
|
||||
var stepNum = 1;
|
||||
for (final step in steps) {
|
||||
buffer.writeln('<div class="step">'
|
||||
'<span class="step-num">$stepNum</span>'
|
||||
'${step.trim()}'
|
||||
'</div>');
|
||||
stepNum++;
|
||||
}
|
||||
buffer.writeln('</div>');
|
||||
}
|
||||
|
||||
// 营养
|
||||
if (recipe.nutrition != null) {
|
||||
final n = recipe.nutrition!;
|
||||
buffer.writeln('<div class="section">');
|
||||
buffer.writeln('<h2>💪 营养信息</h2>');
|
||||
buffer.writeln('<div class="nutrition-grid">');
|
||||
if (n.calories != null) {
|
||||
buffer.writeln('<div class="nutrition-item">'
|
||||
'<div class="nutrition-label">🔥 热量</div>'
|
||||
'<div class="nutrition-value">${n.calories}</div>'
|
||||
'</div>');
|
||||
}
|
||||
if (n.protein != null) {
|
||||
buffer.writeln('<div class="nutrition-item">'
|
||||
'<div class="nutrition-label">💪 蛋白质</div>'
|
||||
'<div class="nutrition-value">${n.protein}</div>'
|
||||
'</div>');
|
||||
}
|
||||
if (n.fat != null) {
|
||||
buffer.writeln('<div class="nutrition-item">'
|
||||
'<div class="nutrition-label">🧈 脂肪</div>'
|
||||
'<div class="nutrition-value">${n.fat}</div>'
|
||||
'</div>');
|
||||
}
|
||||
if (n.carbs != null) {
|
||||
buffer.writeln('<div class="nutrition-item">'
|
||||
'<div class="nutrition-label">🍞 碳水</div>'
|
||||
'<div class="nutrition-value">${n.carbs}</div>'
|
||||
'</div>');
|
||||
}
|
||||
buffer.writeln('</div></div>');
|
||||
}
|
||||
|
||||
buffer.writeln('<div class="footer">— 来自小妈厨房 🏠</div>');
|
||||
buffer.writeln('</body></html>');
|
||||
return buffer.toString();
|
||||
}
|
||||
}
|
||||
@@ -12,27 +12,58 @@ import 'package:mom_kitchen/src/services/ui/theme_service.dart';
|
||||
import 'package:mom_kitchen/src/widgets/glass/nav/glass_nav_bar.dart';
|
||||
import 'package:mom_kitchen/src/widgets/glass/nav/liquid_glass_nav_bar.dart';
|
||||
|
||||
class MainTabView extends StatelessWidget {
|
||||
class MainTabView extends StatefulWidget {
|
||||
const MainTabView({super.key});
|
||||
|
||||
@override
|
||||
State<MainTabView> createState() => _MainTabViewState();
|
||||
}
|
||||
|
||||
class _MainTabViewState extends State<MainTabView> {
|
||||
final _builtPages = <int, Widget>{};
|
||||
static const int _homeIndex = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_builtPages[_homeIndex] = const HomePage();
|
||||
}
|
||||
|
||||
Widget _getPage(int index) {
|
||||
return _builtPages.putIfAbsent(index, () {
|
||||
switch (index) {
|
||||
case 0:
|
||||
return const HomePage();
|
||||
case 1:
|
||||
return const FavoritesPage();
|
||||
case 2:
|
||||
return const DiscoverPage();
|
||||
case 3:
|
||||
return const ProfilePage();
|
||||
default:
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final nav = Get.find<MainNavigationController>();
|
||||
final favoritesController = Get.find<FavoritesController>();
|
||||
|
||||
final pages = [
|
||||
const HomePage(),
|
||||
const FavoritesPage(),
|
||||
const DiscoverPage(),
|
||||
const ProfilePage(),
|
||||
];
|
||||
|
||||
return Obx(() {
|
||||
final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark;
|
||||
final bgColor = isDark
|
||||
? DarkDesignTokens.background
|
||||
: DesignTokens.background;
|
||||
final favoritesCount = favoritesController.favorites.length;
|
||||
int favoritesCount = 0;
|
||||
try {
|
||||
if (Get.isRegistered<FavoritesController>()) {
|
||||
favoritesCount = Get.find<FavoritesController>().favorites.length;
|
||||
}
|
||||
} catch (_) {}
|
||||
final currentIndex = nav.currentIndex.value;
|
||||
|
||||
_getPage(currentIndex);
|
||||
|
||||
final navItems = [
|
||||
GlassNavBarItem(
|
||||
@@ -68,8 +99,13 @@ class MainTabView extends StatelessWidget {
|
||||
child: HeroMode(
|
||||
enabled: false,
|
||||
child: IndexedStack(
|
||||
index: nav.currentIndex.value,
|
||||
children: pages,
|
||||
index: currentIndex,
|
||||
children: List.generate(4, (i) {
|
||||
if (_builtPages.containsKey(i)) {
|
||||
return _builtPages[i]!;
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -84,7 +120,7 @@ class MainTabView extends StatelessWidget {
|
||||
final style = themeService.bottomBarStyle.value;
|
||||
if (style == BottomBarStyle.liquidGlass) {
|
||||
return LiquidGlassNavBar(
|
||||
currentIndex: nav.currentIndex.value,
|
||||
currentIndex: currentIndex,
|
||||
items: navItems
|
||||
.map(
|
||||
(e) => LiquidGlassNavBarItem(
|
||||
@@ -99,7 +135,7 @@ class MainTabView extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
return GlassNavBar(
|
||||
currentIndex: nav.currentIndex.value,
|
||||
currentIndex: currentIndex,
|
||||
items: navItems,
|
||||
onTap: (i) => nav.switchPage(i),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,570 @@
|
||||
// 2026-04-14 | recipe_email_button.dart | 菜谱邮件分享按钮 | 点击弹出输入邮箱对话框并发送菜谱详情
|
||||
// 2026-04-14 | 初始创建,支持收件人邮箱输入+发送状态反馈
|
||||
// 2026-04-14 | 新增多线路选择:官方线路1/线路2/自定义SMTP
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:mom_kitchen/src/config/design_tokens.dart';
|
||||
import 'package:mom_kitchen/src/models/recipe/recipe_model.dart';
|
||||
import 'package:mom_kitchen/src/services/data/email_service.dart';
|
||||
import 'package:mom_kitchen/src/services/ui/toast_service.dart';
|
||||
|
||||
/// 发送线路枚举
|
||||
enum EmailRouteType {
|
||||
/// 官方线路1 (mboxhosting)
|
||||
official1,
|
||||
/// 官方线路2 (QQ邮箱)
|
||||
official2,
|
||||
/// 自定义SMTP
|
||||
custom,
|
||||
}
|
||||
|
||||
/// 菜谱邮件分享按钮
|
||||
/// 在菜谱详情页底部显示,点击后弹出对话框选择线路并输入收件人邮箱
|
||||
class RecipeEmailButton extends StatefulWidget {
|
||||
final RecipeModel recipe;
|
||||
|
||||
const RecipeEmailButton({super.key, required this.recipe});
|
||||
|
||||
@override
|
||||
State<RecipeEmailButton> createState() => _RecipeEmailButtonState();
|
||||
}
|
||||
|
||||
class _RecipeEmailButtonState extends State<RecipeEmailButton> {
|
||||
bool _isSending = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: DesignTokens.space4,
|
||||
vertical: DesignTokens.space3,
|
||||
),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: CupertinoButton(
|
||||
onPressed: _isSending ? null : () => _showEmailDialog(context),
|
||||
borderRadius: DesignTokens.borderRadiusLg,
|
||||
color: isDark ? DarkDesignTokens.primary : DesignTokens.dynamicPrimary,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: DesignTokens.space4,
|
||||
vertical: DesignTokens.space3,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_isSending
|
||||
? const CupertinoActivityIndicator(color: CupertinoColors.white)
|
||||
: const Icon(
|
||||
CupertinoIcons.mail,
|
||||
color: CupertinoColors.white,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: DesignTokens.space2),
|
||||
Text(
|
||||
_isSending ? '发送中...' : '📧 发送菜谱到邮箱',
|
||||
style: const TextStyle(
|
||||
color: CupertinoColors.white,
|
||||
fontSize: DesignTokens.fontLg,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 显示邮件输入对话框
|
||||
void _showEmailDialog(BuildContext context) {
|
||||
showCupertinoModalPopup<void>(
|
||||
context: context,
|
||||
builder: (BuildContext dialogContext) {
|
||||
return _EmailDialog(recipe: widget.recipe);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 邮件发送对话框(StatefulWidget,管理线路选择和输入状态)
|
||||
class _EmailDialog extends StatefulWidget {
|
||||
final RecipeModel recipe;
|
||||
|
||||
const _EmailDialog({required this.recipe});
|
||||
|
||||
@override
|
||||
State<_EmailDialog> createState() => _EmailDialogState();
|
||||
}
|
||||
|
||||
class _EmailDialogState extends State<_EmailDialog> {
|
||||
EmailRouteType _selectedRoute = EmailRouteType.official1;
|
||||
final _recipientController = TextEditingController();
|
||||
final _smtpHostController = TextEditingController();
|
||||
final _smtpPortController = TextEditingController(text: '465');
|
||||
final _smtpUserController = TextEditingController();
|
||||
final _smtpPassController = TextEditingController();
|
||||
bool _isSending = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_recipientController.dispose();
|
||||
_smtpHostController.dispose();
|
||||
_smtpPortController.dispose();
|
||||
_smtpUserController.dispose();
|
||||
_smtpPassController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark;
|
||||
|
||||
return Container(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.78,
|
||||
),
|
||||
padding: const EdgeInsets.only(top: DesignTokens.space2),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? DarkDesignTokens.card : DesignTokens.card,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(DesignTokens.radiusXl),
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 拖拽指示条
|
||||
Container(
|
||||
width: 36,
|
||||
height: 5,
|
||||
margin: const EdgeInsets.symmetric(
|
||||
vertical: DesignTokens.space2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: DesignTokens.text3.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(2.5),
|
||||
),
|
||||
),
|
||||
// 标题栏
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: DesignTokens.space4,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
child: Text(
|
||||
'取消',
|
||||
style: TextStyle(
|
||||
color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2,
|
||||
),
|
||||
),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
Text(
|
||||
'📧 发送菜谱',
|
||||
style: TextStyle(
|
||||
fontSize: DesignTokens.fontLg,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1,
|
||||
),
|
||||
),
|
||||
CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
child: _isSending
|
||||
? const CupertinoActivityIndicator()
|
||||
: Text(
|
||||
'发送',
|
||||
style: TextStyle(
|
||||
color: DesignTokens.dynamicPrimary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
onPressed: _isSending ? null : _handleSend,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: DesignTokens.space2),
|
||||
// 表单区域
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: DesignTokens.space4,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 菜谱信息预览
|
||||
_buildPreviewCard(isDark),
|
||||
const SizedBox(height: DesignTokens.space3),
|
||||
|
||||
// 🔀 发送线路选择
|
||||
_buildSectionLabel('🔀 发送线路', isDark),
|
||||
const SizedBox(height: DesignTokens.space2),
|
||||
..._buildRouteOptions(isDark),
|
||||
const SizedBox(height: DesignTokens.space3),
|
||||
|
||||
// 自定义SMTP输入区(仅在自定义线路时显示)
|
||||
if (_selectedRoute == EmailRouteType.custom) ...[
|
||||
_buildSectionLabel('⚙️ SMTP 配置', isDark),
|
||||
const SizedBox(height: DesignTokens.space1),
|
||||
_buildTextField(
|
||||
controller: _smtpHostController,
|
||||
placeholder: 'SMTP 服务器(如 smtp.qq.com)',
|
||||
keyboardType: TextInputType.url,
|
||||
isDark: isDark,
|
||||
),
|
||||
const SizedBox(height: DesignTokens.space2),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: _buildTextField(
|
||||
controller: _smtpUserController,
|
||||
placeholder: '邮箱账号',
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
isDark: isDark,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: DesignTokens.space2),
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: _buildTextField(
|
||||
controller: _smtpPortController,
|
||||
placeholder: '端口',
|
||||
keyboardType: TextInputType.number,
|
||||
isDark: isDark,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: DesignTokens.space2),
|
||||
_buildTextField(
|
||||
controller: _smtpPassController,
|
||||
placeholder: '授权码 / 密码',
|
||||
obscureText: true,
|
||||
isDark: isDark,
|
||||
),
|
||||
const SizedBox(height: DesignTokens.space2),
|
||||
Text(
|
||||
'💡 需使用邮箱SMTP授权码(非登录密码),可在邮箱设置中获取。端口465=SSL,587=STARTTLS。',
|
||||
style: TextStyle(
|
||||
fontSize: DesignTokens.fontXs,
|
||||
color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: DesignTokens.space3),
|
||||
],
|
||||
|
||||
// 📨 收件人邮箱
|
||||
_buildSectionLabel('📨 收件人邮箱', isDark),
|
||||
const SizedBox(height: DesignTokens.space1),
|
||||
_buildTextField(
|
||||
controller: _recipientController,
|
||||
placeholder: '请输入收件人邮箱地址',
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
isDark: isDark,
|
||||
),
|
||||
const SizedBox(height: DesignTokens.space2),
|
||||
Text(
|
||||
'💡 菜谱详情(食材、步骤、营养信息)将以精美HTML邮件发送',
|
||||
style: TextStyle(
|
||||
fontSize: DesignTokens.fontXs,
|
||||
color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: DesignTokens.space4),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建线路选择选项
|
||||
List<Widget> _buildRouteOptions(bool isDark) {
|
||||
return EmailRouteType.values.map((route) {
|
||||
final isSelected = _selectedRoute == route;
|
||||
final routeInfo = route == EmailRouteType.custom
|
||||
? null
|
||||
: EmailService.presetRoutes[route.index];
|
||||
final label = route == EmailRouteType.custom
|
||||
? '🔧 自定义SMTP'
|
||||
: '${routeInfo!.icon} ${routeInfo.name}';
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: DesignTokens.space2),
|
||||
child: GestureDetector(
|
||||
onTap: () => setState(() => _selectedRoute = route),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: DesignTokens.space3,
|
||||
vertical: DesignTokens.space3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? DesignTokens.dynamicPrimaryLight
|
||||
: (isDark ? DarkDesignTokens.background : DesignTokens.background),
|
||||
borderRadius: DesignTokens.borderRadiusMd,
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? DesignTokens.dynamicPrimary
|
||||
: DesignTokens.text3.withValues(alpha: 0.15),
|
||||
width: isSelected ? 1.5 : 0.5,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 选中指示器
|
||||
Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: isSelected
|
||||
? DesignTokens.dynamicPrimary
|
||||
: CupertinoColors.transparent,
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? DesignTokens.dynamicPrimary
|
||||
: (isDark ? DarkDesignTokens.text3 : DesignTokens.text3),
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
child: isSelected
|
||||
? const Icon(CupertinoIcons.checkmark, size: 12, color: CupertinoColors.white)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: DesignTokens.space3),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: DesignTokens.fontMd,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isSelected
|
||||
? DesignTokens.dynamicPrimary
|
||||
: (isDark ? DarkDesignTokens.text1 : DesignTokens.text1),
|
||||
),
|
||||
),
|
||||
if (routeInfo != null)
|
||||
Text(
|
||||
'${routeInfo.username} · ${routeInfo.host}',
|
||||
style: TextStyle(
|
||||
fontSize: DesignTokens.fontXs,
|
||||
color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
|
||||
),
|
||||
),
|
||||
if (route == EmailRouteType.custom)
|
||||
Text(
|
||||
'使用自己的邮箱服务器发送',
|
||||
style: TextStyle(
|
||||
fontSize: DesignTokens.fontXs,
|
||||
color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// 菜谱信息预览卡片
|
||||
Widget _buildPreviewCard(bool isDark) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(DesignTokens.space3),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? DarkDesignTokens.background : DesignTokens.background,
|
||||
borderRadius: DesignTokens.borderRadiusMd,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: DesignTokens.dynamicPrimaryLight,
|
||||
borderRadius: DesignTokens.borderRadiusSm,
|
||||
),
|
||||
child: const Center(
|
||||
child: Text('🍳', style: TextStyle(fontSize: 24)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: DesignTokens.space3),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.recipe.title,
|
||||
style: TextStyle(
|
||||
fontSize: DesignTokens.fontMd,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (widget.recipe.categoryName != null)
|
||||
Text(
|
||||
widget.recipe.categoryName!,
|
||||
style: TextStyle(
|
||||
fontSize: DesignTokens.fontXs,
|
||||
color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
CupertinoIcons.doc_richtext,
|
||||
size: 18,
|
||||
color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 区域标签
|
||||
Widget _buildSectionLabel(String label, bool isDark) {
|
||||
return Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: DesignTokens.fontSm,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 统一输入框
|
||||
Widget _buildTextField({
|
||||
required TextEditingController controller,
|
||||
required String placeholder,
|
||||
required bool isDark,
|
||||
TextInputType? keyboardType,
|
||||
bool obscureText = false,
|
||||
}) {
|
||||
return CupertinoTextField(
|
||||
controller: controller,
|
||||
placeholder: placeholder,
|
||||
placeholderStyle: TextStyle(
|
||||
color: DesignTokens.text3,
|
||||
fontSize: DesignTokens.fontMd,
|
||||
),
|
||||
style: TextStyle(
|
||||
color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1,
|
||||
fontSize: DesignTokens.fontMd,
|
||||
),
|
||||
keyboardType: keyboardType,
|
||||
obscureText: obscureText,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: DesignTokens.space3,
|
||||
vertical: DesignTokens.space3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? DarkDesignTokens.background : DesignTokens.background,
|
||||
borderRadius: DesignTokens.borderRadiusMd,
|
||||
border: Border.all(
|
||||
color: DesignTokens.text3.withValues(alpha: 0.15),
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 处理发送
|
||||
Future<void> _handleSend() async {
|
||||
final recipientEmail = _recipientController.text.trim();
|
||||
|
||||
// 校验收件人
|
||||
if (recipientEmail.isEmpty) {
|
||||
ToastService.warning('请输入收件人邮箱');
|
||||
return;
|
||||
}
|
||||
if (!_isValidEmail(recipientEmail)) {
|
||||
ToastService.warning('请输入正确的邮箱格式');
|
||||
return;
|
||||
}
|
||||
|
||||
// 自定义线路校验
|
||||
if (_selectedRoute == EmailRouteType.custom) {
|
||||
if (_smtpHostController.text.trim().isEmpty) {
|
||||
ToastService.warning('请输入SMTP服务器地址');
|
||||
return;
|
||||
}
|
||||
if (_smtpUserController.text.trim().isEmpty) {
|
||||
ToastService.warning('请输入SMTP邮箱账号');
|
||||
return;
|
||||
}
|
||||
if (_smtpPassController.text.trim().isEmpty) {
|
||||
ToastService.warning('请输入SMTP授权码');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setState(() => _isSending = true);
|
||||
|
||||
try {
|
||||
bool success;
|
||||
|
||||
if (_selectedRoute == EmailRouteType.custom) {
|
||||
success = await EmailService.sendRecipeEmailCustom(
|
||||
recipientEmail: recipientEmail,
|
||||
recipe: widget.recipe,
|
||||
smtpHost: _smtpHostController.text.trim(),
|
||||
smtpPort: int.tryParse(_smtpPortController.text.trim()) ?? 465,
|
||||
smtpUsername: _smtpUserController.text.trim(),
|
||||
smtpPassword: _smtpPassController.text.trim(),
|
||||
);
|
||||
} else {
|
||||
success = await EmailService.sendRecipeEmail(
|
||||
recipientEmail: recipientEmail,
|
||||
recipe: widget.recipe,
|
||||
routeIndex: _selectedRoute.index,
|
||||
);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
if (success) {
|
||||
ToastService.success('📧 菜谱已发送到 $recipientEmail');
|
||||
Navigator.pop(context);
|
||||
} else {
|
||||
ToastService.error('邮件发送失败,请更换线路重试');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ToastService.error('发送异常:$e');
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isSending = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 简单邮箱格式校验
|
||||
bool _isValidEmail(String email) {
|
||||
return RegExp(r'^[\w.-]+@[\w.-]+\.\w+$').hasMatch(email);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user