修复 断点异常

This commit is contained in:
Developer
2026-04-15 07:11:28 +08:00
parent 58730c5bff
commit 08dce0aed0
210 changed files with 32037 additions and 872 deletions

View File

@@ -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()),
);

View File

@@ -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);
}
}

View File

@@ -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(),
),
]);
}
}

View 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();
}
}

View File

@@ -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,

View File

@@ -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 {

View File

@@ -20,7 +20,7 @@ class FeedController extends BaseController {
@override
void onInit() {
super.onInit();
loadFeed();
Future.delayed(Duration.zero, () => loadFeed());
}
void switchFeed(FeedType type) {

View File

@@ -18,7 +18,7 @@ class HotController extends BaseController {
@override
void onInit() {
super.onInit();
loadHot();
Future.delayed(Duration.zero, () => loadHot());
}
void switchPeriod(HotPeriod period) {

View File

@@ -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 {

View File

@@ -47,7 +47,7 @@ class WhatToEatController extends BaseController {
@override
void onInit() {
super.onInit();
_loadOptionsSafe();
Future.delayed(Duration.zero, () => _loadOptionsSafe());
}
Future<void> _loadOptionsSafe() async {

View File

@@ -15,8 +15,10 @@ class OnlineController extends BaseController {
@override
void onInit() {
super.onInit();
loadOnlineStats();
startHeartbeat();
Future.delayed(Duration.zero, () {
loadOnlineStats();
startHeartbeat();
});
}
@override

View File

@@ -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 {

View 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?,
);
}
}

View File

@@ -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';

View File

@@ -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),
],
),
),

View 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('清空'),
),
],
),
);
}
}

View File

@@ -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,
),
],
),
),
],
),
);

View File

@@ -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';

View 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,
),
),
],
),
),
),
],
),
),
);
}
}

View 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();
}
}

View File

@@ -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),
);

View File

@@ -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=SSL587=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);
}
}