feat: 切换邮箱认证并重构前后端启动与门禁

This commit is contained in:
qzl
2026-04-02 18:39:35 +08:00
parent 92cdfd9fca
commit 31594558eb
116 changed files with 5608 additions and 628 deletions
@@ -0,0 +1,136 @@
import 'package:flutter/material.dart';
@immutable
class AppColorPalette extends ThemeExtension<AppColorPalette> {
const AppColorPalette({
required this.accentPurple,
required this.historyGoldBg,
required this.historyGoldText,
required this.historyBlueBg,
required this.historyBlueText,
required this.historyGrayBg,
required this.historyGrayText,
required this.categoryCareerBg,
required this.categoryCareerText,
required this.categoryLoveBg,
required this.categoryLoveText,
required this.categoryMoneyBg,
required this.categoryMoneyText,
required this.notificationDot,
required this.warning,
required this.warningContainer,
required this.onWarningContainer,
});
final Color accentPurple;
final Color historyGoldBg;
final Color historyGoldText;
final Color historyBlueBg;
final Color historyBlueText;
final Color historyGrayBg;
final Color historyGrayText;
final Color categoryCareerBg;
final Color categoryCareerText;
final Color categoryLoveBg;
final Color categoryLoveText;
final Color categoryMoneyBg;
final Color categoryMoneyText;
final Color notificationDot;
final Color warning;
final Color warningContainer;
final Color onWarningContainer;
@override
ThemeExtension<AppColorPalette> copyWith({
Color? accentPurple,
Color? historyGoldBg,
Color? historyGoldText,
Color? historyBlueBg,
Color? historyBlueText,
Color? historyGrayBg,
Color? historyGrayText,
Color? categoryCareerBg,
Color? categoryCareerText,
Color? categoryLoveBg,
Color? categoryLoveText,
Color? categoryMoneyBg,
Color? categoryMoneyText,
Color? notificationDot,
Color? warning,
Color? warningContainer,
Color? onWarningContainer,
}) {
return AppColorPalette(
accentPurple: accentPurple ?? this.accentPurple,
historyGoldBg: historyGoldBg ?? this.historyGoldBg,
historyGoldText: historyGoldText ?? this.historyGoldText,
historyBlueBg: historyBlueBg ?? this.historyBlueBg,
historyBlueText: historyBlueText ?? this.historyBlueText,
historyGrayBg: historyGrayBg ?? this.historyGrayBg,
historyGrayText: historyGrayText ?? this.historyGrayText,
categoryCareerBg: categoryCareerBg ?? this.categoryCareerBg,
categoryCareerText: categoryCareerText ?? this.categoryCareerText,
categoryLoveBg: categoryLoveBg ?? this.categoryLoveBg,
categoryLoveText: categoryLoveText ?? this.categoryLoveText,
categoryMoneyBg: categoryMoneyBg ?? this.categoryMoneyBg,
categoryMoneyText: categoryMoneyText ?? this.categoryMoneyText,
notificationDot: notificationDot ?? this.notificationDot,
warning: warning ?? this.warning,
warningContainer: warningContainer ?? this.warningContainer,
onWarningContainer: onWarningContainer ?? this.onWarningContainer,
);
}
@override
ThemeExtension<AppColorPalette> lerp(
covariant ThemeExtension<AppColorPalette>? other,
double t,
) {
if (other is! AppColorPalette) {
return this;
}
return AppColorPalette(
accentPurple: Color.lerp(accentPurple, other.accentPurple, t)!,
historyGoldBg: Color.lerp(historyGoldBg, other.historyGoldBg, t)!,
historyGoldText: Color.lerp(historyGoldText, other.historyGoldText, t)!,
historyBlueBg: Color.lerp(historyBlueBg, other.historyBlueBg, t)!,
historyBlueText: Color.lerp(historyBlueText, other.historyBlueText, t)!,
historyGrayBg: Color.lerp(historyGrayBg, other.historyGrayBg, t)!,
historyGrayText: Color.lerp(historyGrayText, other.historyGrayText, t)!,
categoryCareerBg: Color.lerp(
categoryCareerBg,
other.categoryCareerBg,
t,
)!,
categoryCareerText: Color.lerp(
categoryCareerText,
other.categoryCareerText,
t,
)!,
categoryLoveBg: Color.lerp(categoryLoveBg, other.categoryLoveBg, t)!,
categoryLoveText: Color.lerp(
categoryLoveText,
other.categoryLoveText,
t,
)!,
categoryMoneyBg: Color.lerp(categoryMoneyBg, other.categoryMoneyBg, t)!,
categoryMoneyText: Color.lerp(
categoryMoneyText,
other.categoryMoneyText,
t,
)!,
notificationDot: Color.lerp(notificationDot, other.notificationDot, t)!,
warning: Color.lerp(warning, other.warning, t)!,
warningContainer: Color.lerp(
warningContainer,
other.warningContainer,
t,
)!,
onWarningContainer: Color.lerp(
onWarningContainer,
other.onWarningContainer,
t,
)!,
);
}
}
+17
View File
@@ -0,0 +1,17 @@
class AppSpacing {
static const double xs = 4;
static const double sm = 8;
static const double md = 12;
static const double lg = 16;
static const double xl = 24;
static const double xxl = 32;
static const double xxxl = 60;
}
class AppRadius {
static const double sm = 8;
static const double md = 12;
static const double lg = 16;
static const double xl = 20;
static const double full = 999;
}
+76
View File
@@ -0,0 +1,76 @@
import 'package:flutter/material.dart';
import '../theme/design_tokens.dart';
import 'toast/toast_type.dart';
import 'toast/toast_type_config.dart' show ToastTypeConfig;
class AppBanner extends StatelessWidget {
final String message;
final ToastType type;
final bool visible;
final String? title;
const AppBanner({
super.key,
required this.message,
this.type = ToastType.warning,
this.visible = true,
this.title,
});
@override
Widget build(BuildContext context) {
if (!visible) return const SizedBox.shrink();
final config = ToastTypeConfig.fromType(context, type);
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
decoration: BoxDecoration(
color: config.surfaceColor,
borderRadius: BorderRadius.circular(AppRadius.md),
border: Border.all(color: config.borderColor),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: config.iconColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(AppRadius.full),
),
child: Icon(config.icon, size: 16, color: config.iconColor),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title ?? config.label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: config.textColor,
),
),
const SizedBox(height: 2),
Text(
message,
style: TextStyle(
fontSize: 13,
height: 1.35,
color: config.textColor,
),
),
],
),
),
],
),
);
}
}
@@ -0,0 +1,104 @@
import 'package:flutter/material.dart';
import '../theme/design_tokens.dart';
enum AppLoadingVariant { surface, inline, button }
class AppLoadingIndicator extends StatelessWidget {
const AppLoadingIndicator({
super.key,
this.variant = AppLoadingVariant.surface,
this.size,
this.strokeWidth,
this.color,
this.trackColor,
this.withContainer,
});
final AppLoadingVariant variant;
final double? size;
final double? strokeWidth;
final Color? color;
final Color? trackColor;
final bool? withContainer;
double get _resolvedSize {
return size ??
switch (variant) {
AppLoadingVariant.surface => 22,
AppLoadingVariant.inline => 16,
AppLoadingVariant.button => 18,
};
}
double get _resolvedStrokeWidth {
return strokeWidth ??
switch (variant) {
AppLoadingVariant.surface => 2.2,
AppLoadingVariant.inline => 2,
AppLoadingVariant.button => 2.2,
};
}
Widget _buildSpinner(Color color, Color trackColor) {
return SizedBox(
width: _resolvedSize,
height: _resolvedSize,
child: CircularProgressIndicator(
strokeWidth: _resolvedStrokeWidth,
color: color,
backgroundColor: trackColor,
),
);
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final resolvedColor =
color ??
switch (variant) {
AppLoadingVariant.surface => colorScheme.primary,
AppLoadingVariant.inline => colorScheme.onSurfaceVariant,
AppLoadingVariant.button => colorScheme.onPrimary,
};
final resolvedTrackColor =
trackColor ??
switch (variant) {
AppLoadingVariant.surface => colorScheme.primaryContainer,
AppLoadingVariant.inline => colorScheme.outlineVariant,
AppLoadingVariant.button => colorScheme.secondary,
};
if (withContainer == false ||
(withContainer == null &&
switch (variant) {
AppLoadingVariant.surface => true,
AppLoadingVariant.inline => false,
AppLoadingVariant.button => false,
})) {
return _buildSpinner(resolvedColor, resolvedTrackColor);
}
return Container(
width: _resolvedSize + AppSpacing.md,
height: _resolvedSize + AppSpacing.md,
padding: const EdgeInsets.all(AppSpacing.xs),
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: BorderRadius.circular(AppRadius.full),
border: Border.all(color: colorScheme.outlineVariant),
boxShadow: [
BoxShadow(
color: colorScheme.outlineVariant.withValues(alpha: 0.55),
blurRadius: AppRadius.md,
offset: const Offset(0, AppSpacing.xs),
),
],
),
child: _buildSpinner(resolvedColor, resolvedTrackColor),
);
}
}
+103
View File
@@ -0,0 +1,103 @@
import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart';
import '../theme/design_tokens.dart';
enum MainTab { home, profile }
class BottomNavBar extends StatelessWidget {
const BottomNavBar({
super.key,
required this.currentTab,
required this.onTabChange,
required this.onLogoTap,
});
final MainTab currentTab;
final ValueChanged<MainTab> onTabChange;
final VoidCallback onLogoTap;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final colors = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm),
color: colors.surface,
child: SafeArea(
top: false,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_NavItem(
icon: Icons.home,
label: l10n.homeTab,
selected: currentTab == MainTab.home,
onTap: () => onTabChange(MainTab.home),
),
GestureDetector(
onTap: onLogoTap,
child: ClipRRect(
borderRadius: BorderRadius.circular(AppRadius.lg),
child: Image.asset(
'assets/images/logo.png',
width: 56,
height: 56,
fit: BoxFit.cover,
),
),
),
_NavItem(
icon: Icons.person,
label: l10n.profileTab,
selected: currentTab == MainTab.profile,
onTap: () => onTabChange(MainTab.profile),
),
],
),
),
);
}
}
class _NavItem extends StatelessWidget {
const _NavItem({
required this.icon,
required this.label,
required this.selected,
required this.onTap,
});
final IconData icon;
final String label;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final iconColor = selected ? colors.primary : colors.outline;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.md),
child: Padding(
padding: const EdgeInsets.all(AppSpacing.sm),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: iconColor),
const SizedBox(height: AppSpacing.xs),
Text(
label,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: iconColor),
),
],
),
),
);
}
}
+183
View File
@@ -0,0 +1,183 @@
import 'package:flutter/material.dart';
import '../../theme/design_tokens.dart';
import 'toast_type.dart';
import 'toast_type_config.dart';
class Toast {
static void show(
BuildContext context,
String message, {
ToastType type = ToastType.info,
Duration duration = const Duration(seconds: 2),
}) {
final overlay = Overlay.of(context);
late OverlayEntry entry;
entry = OverlayEntry(
builder: (context) => _ToastWidget(
message: message,
type: type,
duration: duration,
onDismiss: () => entry.remove(),
),
);
overlay.insert(entry);
}
}
class _ToastWidget extends StatefulWidget {
final String message;
final ToastType type;
final Duration duration;
final VoidCallback onDismiss;
const _ToastWidget({
required this.message,
required this.type,
required this.duration,
required this.onDismiss,
});
@override
State<_ToastWidget> createState() => _ToastWidgetState();
}
class _ToastWidgetState extends State<_ToastWidget>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<Offset> _slideAnimation;
late Animation<double> _fadeAnimation;
bool _dismissed = false;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 280),
vsync: this,
);
_slideAnimation = Tween<Offset>(
begin: const Offset(0, -0.18),
end: Offset.zero,
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic));
_fadeAnimation = Tween<double>(
begin: 0,
end: 1,
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut));
_controller.forward();
Future.delayed(widget.duration, _dismiss);
}
void _dismiss() {
if (!mounted || _dismissed) return;
_dismissed = true;
_controller.reverse().then((_) {
if (mounted) {
widget.onDismiss();
}
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final config = ToastTypeConfig.fromType(context, widget.type);
final colorScheme = Theme.of(context).colorScheme;
return Positioned(
top: MediaQuery.of(context).padding.top + 12,
left: 16,
right: 16,
child: SlideTransition(
position: _slideAnimation,
child: FadeTransition(
opacity: _fadeAnimation,
child: Material(
color: Colors.transparent,
child: SafeArea(
bottom: false,
child: GestureDetector(
onTap: _dismiss,
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: config.surfaceColor,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(color: config.borderColor),
boxShadow: [
BoxShadow(
color: colorScheme.shadow.withValues(alpha: 0.08),
blurRadius: 24,
offset: const Offset(0, 10),
),
],
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: config.iconColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(AppRadius.full),
),
child: Icon(
config.icon,
size: 18,
color: config.iconColor,
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
config.label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: config.textColor,
),
),
const SizedBox(height: 2),
Text(
widget.message,
style: TextStyle(
fontSize: 14,
height: 1.35,
color: config.textColor,
),
),
],
),
),
const SizedBox(width: 8),
Icon(
Icons.close_rounded,
size: 18,
color: config.textColor.withValues(alpha: 0.72),
),
],
),
),
),
),
),
),
),
);
}
}
@@ -0,0 +1 @@
enum ToastType { info, success, warning, error }
@@ -0,0 +1,64 @@
import 'package:flutter/material.dart';
import '../../../l10n/app_localizations.dart';
import '../../theme/app_color_palette.dart';
import 'toast_type.dart';
class ToastTypeConfig {
final Color surfaceColor;
final Color borderColor;
final Color iconColor;
final Color textColor;
final String label;
final IconData icon;
const ToastTypeConfig({
required this.surfaceColor,
required this.borderColor,
required this.iconColor,
required this.textColor,
required this.label,
required this.icon,
});
static ToastTypeConfig fromType(BuildContext context, ToastType type) {
final l10n = AppLocalizations.of(context)!;
final colorScheme = Theme.of(context).colorScheme;
final palette = Theme.of(context).extension<AppColorPalette>()!;
return switch (type) {
ToastType.success => ToastTypeConfig(
surfaceColor: colorScheme.primaryContainer,
borderColor: colorScheme.primary,
iconColor: colorScheme.primary,
textColor: colorScheme.onPrimaryContainer,
label: l10n.toastLabelSuccess,
icon: Icons.check_circle_outline,
),
ToastType.warning => ToastTypeConfig(
surfaceColor: palette.warningContainer,
borderColor: palette.warning,
iconColor: palette.warning,
textColor: palette.onWarningContainer,
label: l10n.toastLabelWarning,
icon: Icons.warning_amber_rounded,
),
ToastType.error => ToastTypeConfig(
surfaceColor: colorScheme.errorContainer,
borderColor: colorScheme.error,
iconColor: colorScheme.error,
textColor: colorScheme.onErrorContainer,
label: l10n.toastLabelError,
icon: Icons.error_outline,
),
ToastType.info => ToastTypeConfig(
surfaceColor: colorScheme.primaryContainer,
borderColor: colorScheme.primary,
iconColor: colorScheme.primary,
textColor: colorScheme.onPrimaryContainer,
label: l10n.toastLabelInfo,
icon: Icons.info_outline,
),
};
}
}