## UI 配色 - 健康仪表盘: 背景从蓝紫粉三色渐变改为 #4FACFE 纯色蓝, 白字清晰不抢眼 - app_colors / app_design_tokens / app_theme: 配色体系微调 - 多个 widget 和页面跟随配色更新(health_drawer/admin_drawer/doctor_drawer/enterprise_widgets 等) ## 登录页品牌升级 - 新增品牌素材: drawer_background_v2 / login_background_v2 / health_login_character_transparent - login_page 重构, 接入新品牌视觉 - app.dart 启动流程调整 ## iOS / Android 配置 - Info.plist: 权限描述调整 - AppIcon / LaunchImage: 资源更新 - AndroidManifest: 权限微调 ## 隐私文案修订 - privacy.html: 蓝牙设备描述调整为"标准蓝牙血压服务", 移除未上线设备类型表述 - terms.html: 移除"在线医生咨询"相关条款, 服务范围收窄 ## 通知中心 - notification_center_page: 重构通知项布局 ## 其他 - 删除已完成的计划/spec 文档(ui-system-first-pass / apple-sign-in / secondary-page-color-refresh / notification-preferences-design / ui-design-system) - 新增 native_navigation_test - 新增 HANDOFF 交接文档 - home_message_order_test 更新 - .gitignore 调整
992 lines
33 KiB
Dart
992 lines
33 KiB
Dart
import 'dart:io' show Platform;
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
|
import 'package:sign_in_with_apple/sign_in_with_apple.dart';
|
|
import '../../core/app_colors.dart';
|
|
import '../../core/app_design_tokens.dart';
|
|
import '../../core/navigation_provider.dart';
|
|
import '../../providers/auth_provider.dart';
|
|
import '../../providers/data_providers.dart';
|
|
import '../../widgets/app_gradient_widgets.dart';
|
|
|
|
class LoginPage extends ConsumerStatefulWidget {
|
|
const LoginPage({super.key});
|
|
@override
|
|
ConsumerState<LoginPage> createState() => _LoginPageState();
|
|
}
|
|
|
|
class _LoginPageState extends ConsumerState<LoginPage> {
|
|
static const _loginBg = 'assets/branding/login_background_v2.png';
|
|
static const _loginActionGradient = AppColors.primaryGradient;
|
|
|
|
void _openStaticText(String type) {
|
|
pushRoute(ref, 'staticText', params: {'type': type});
|
|
}
|
|
|
|
final _phoneCtrl = TextEditingController();
|
|
final _codeCtrl = TextEditingController();
|
|
final _nameCtrl = TextEditingController();
|
|
String? _selectedDoctorId;
|
|
String? _selectedDoctorName;
|
|
bool _agreed = false;
|
|
bool _sending = false;
|
|
int _countdown = 0;
|
|
bool _loading = false;
|
|
String? _error;
|
|
bool _isLogin = true;
|
|
String? _successMsg;
|
|
|
|
@override
|
|
void dispose() {
|
|
_phoneCtrl.dispose();
|
|
_codeCtrl.dispose();
|
|
_nameCtrl.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _sendSms() async {
|
|
final phone = _phoneCtrl.text.trim();
|
|
if (phone.length != 11 || !phone.startsWith('1')) {
|
|
setState(() => _error = '请输入正确的手机号');
|
|
return;
|
|
}
|
|
setState(() {
|
|
_sending = true;
|
|
_error = null;
|
|
});
|
|
final result = await ref.read(authProvider.notifier).sendSms(phone);
|
|
setState(() => _sending = false);
|
|
if (result.error != null) {
|
|
setState(() => _error = result.error);
|
|
return;
|
|
}
|
|
if (!mounted) return;
|
|
if (kDebugMode && result.devCode != null) _codeCtrl.text = result.devCode!;
|
|
setState(() => _countdown = 60);
|
|
_startCountdown();
|
|
}
|
|
|
|
void _startCountdown() async {
|
|
for (var i = 60; i > 0; i--) {
|
|
await Future.delayed(const Duration(seconds: 1));
|
|
if (mounted) setState(() => _countdown = i - 1);
|
|
}
|
|
}
|
|
|
|
// Apple 登录
|
|
Future<void> _appleSignIn() async {
|
|
if (_loading) return;
|
|
if (!_agreed) {
|
|
final accepted = await _showAgreementDialog();
|
|
if (!mounted || accepted != true) return;
|
|
setState(() => _agreed = true);
|
|
}
|
|
setState(() {
|
|
_loading = true;
|
|
_error = null;
|
|
_successMsg = null;
|
|
});
|
|
|
|
try {
|
|
final credential = await SignInWithApple.getAppleIDCredential(
|
|
scopes: [AppleIDAuthorizationScopes.fullName],
|
|
);
|
|
|
|
final identityToken = credential.identityToken;
|
|
if (identityToken == null) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_loading = false;
|
|
_error = 'Apple 登录未返回身份信息,请重试';
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
final name = '${credential.familyName ?? ''}${credential.givenName ?? ''}'
|
|
.trim();
|
|
|
|
final err = await ref
|
|
.read(authProvider.notifier)
|
|
.appleLogin(
|
|
identityToken,
|
|
credential.authorizationCode,
|
|
name.isEmpty ? null : name,
|
|
);
|
|
if (mounted) {
|
|
setState(() => _loading = false);
|
|
if (err != null) {
|
|
setState(() => _error = err);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_loading = false;
|
|
if (e is SignInWithAppleAuthorizationException) {
|
|
_error = 'Apple 登录已取消';
|
|
} else {
|
|
_error = 'Apple 登录失败,请稍后重试';
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _submit() async {
|
|
if (_phoneCtrl.text.trim().isEmpty || _codeCtrl.text.trim().isEmpty) {
|
|
setState(() => _error = '请输入手机号和验证码');
|
|
return;
|
|
}
|
|
if (!_isLogin && _nameCtrl.text.trim().isEmpty) {
|
|
setState(() => _error = '请输入姓名');
|
|
return;
|
|
}
|
|
if (!_isLogin && _selectedDoctorId == null) {
|
|
setState(() => _error = '请选择健康服务医生');
|
|
return;
|
|
}
|
|
if (!_agreed) {
|
|
final accepted = await _showAgreementDialog();
|
|
if (!mounted || accepted != true) return;
|
|
setState(() {
|
|
_agreed = true;
|
|
_error = null;
|
|
});
|
|
}
|
|
setState(() {
|
|
_loading = true;
|
|
_error = null;
|
|
_successMsg = null;
|
|
});
|
|
|
|
String? err;
|
|
if (_isLogin) {
|
|
err = await ref
|
|
.read(authProvider.notifier)
|
|
.login(_phoneCtrl.text.trim(), _codeCtrl.text.trim());
|
|
} else {
|
|
err = await ref
|
|
.read(authProvider.notifier)
|
|
.register(
|
|
_phoneCtrl.text.trim(),
|
|
_codeCtrl.text.trim(),
|
|
_nameCtrl.text.trim(),
|
|
_selectedDoctorId!,
|
|
);
|
|
}
|
|
|
|
if (!mounted) return;
|
|
setState(() => _loading = false);
|
|
if (err != null) {
|
|
if (err.contains('未注册')) setState(() => _isLogin = false);
|
|
setState(() => _error = err);
|
|
return;
|
|
}
|
|
// 登录和注册成功后的页面切换统一由根导航监听认证状态处理。
|
|
}
|
|
|
|
Future<bool?> _showAgreementDialog() {
|
|
return showDialog<bool>(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
barrierColor: Colors.black.withValues(alpha: 0.54),
|
|
builder: (ctx) => Dialog(
|
|
insetPadding: const EdgeInsets.symmetric(horizontal: 28),
|
|
backgroundColor: Colors.transparent,
|
|
child: Container(
|
|
width: double.infinity,
|
|
constraints: const BoxConstraints(maxWidth: 380),
|
|
padding: const EdgeInsets.fromLTRB(22, 26, 22, 22),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: AppRadius.xlBorder,
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.14),
|
|
blurRadius: 34,
|
|
offset: const Offset(0, 18),
|
|
),
|
|
],
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Text(
|
|
'服务协议及隐私保护',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
fontSize: 21,
|
|
fontWeight: FontWeight.w700,
|
|
color: AppColors.textPrimary,
|
|
letterSpacing: 0,
|
|
),
|
|
),
|
|
const SizedBox(height: 18),
|
|
RichText(
|
|
textAlign: TextAlign.center,
|
|
text: TextSpan(
|
|
style: const TextStyle(
|
|
fontSize: 15,
|
|
height: 1.55,
|
|
fontWeight: FontWeight.w700,
|
|
color: AppColors.textSecondary,
|
|
),
|
|
children: [
|
|
const TextSpan(text: '请阅读并同意'),
|
|
WidgetSpan(
|
|
alignment: PlaceholderAlignment.baseline,
|
|
baseline: TextBaseline.alphabetic,
|
|
child: GestureDetector(
|
|
onTap: () {
|
|
Navigator.pop(ctx, false);
|
|
_openStaticText('terms');
|
|
},
|
|
child: AppGradientText(
|
|
'《服务协议》',
|
|
style: TextStyle(
|
|
fontSize: 15,
|
|
height: 1.55,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const TextSpan(text: '和'),
|
|
WidgetSpan(
|
|
alignment: PlaceholderAlignment.baseline,
|
|
baseline: TextBaseline.alphabetic,
|
|
child: GestureDetector(
|
|
onTap: () {
|
|
Navigator.pop(ctx, false);
|
|
_openStaticText('privacy');
|
|
},
|
|
child: AppGradientText(
|
|
'《隐私政策》',
|
|
style: TextStyle(
|
|
fontSize: 15,
|
|
height: 1.55,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 26),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: AppGradientOutlineButton(
|
|
label: '不同意',
|
|
onTap: () => Navigator.pop(ctx, false),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: GestureDetector(
|
|
onTap: () => Navigator.pop(ctx, true),
|
|
child: Container(
|
|
height: 50,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
gradient: _loginActionGradient,
|
|
borderRadius: AppRadius.mdBorder,
|
|
boxShadow: AppColors.buttonShadow,
|
|
),
|
|
child: const Text(
|
|
'同意',
|
|
style: TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showDoctorPicker() {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
backgroundColor: Colors.white,
|
|
showDragHandle: true,
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl)),
|
|
),
|
|
builder: (ctx) => Consumer(
|
|
builder: (ctx, ref, _) {
|
|
final doctorsAsync = ref.watch(doctorListProvider);
|
|
return doctorsAsync.when(
|
|
data: (doctors) {
|
|
final active = doctors
|
|
.where((d) => d['isActive'] != false)
|
|
.toList();
|
|
if (active.isEmpty) {
|
|
return const SizedBox(
|
|
height: 200,
|
|
child: Center(child: Text('暂无可用医生')),
|
|
);
|
|
}
|
|
return SizedBox(
|
|
height: 420,
|
|
child: Column(
|
|
children: [
|
|
const Padding(
|
|
padding: EdgeInsets.fromLTRB(20, 2, 20, 16),
|
|
child: Text(
|
|
'选择健康服务医生',
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppColors.textPrimary,
|
|
),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: ListView.separated(
|
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
|
itemCount: active.length,
|
|
separatorBuilder: (_, _) => const Padding(
|
|
padding: EdgeInsets.only(left: 50),
|
|
child: Divider(height: 1, color: AppColors.divider),
|
|
),
|
|
itemBuilder: (_, i) {
|
|
final d = active[i];
|
|
final name = d['name'] ?? '';
|
|
final title = d['title'] ?? '';
|
|
final dept = d['department'] ?? '';
|
|
return ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
leading: Container(
|
|
width: 38,
|
|
height: 38,
|
|
decoration: BoxDecoration(
|
|
color: AppColors.primarySoft,
|
|
borderRadius: AppRadius.smBorder,
|
|
),
|
|
child: const Icon(
|
|
LucideIcons.stethoscope,
|
|
size: 20,
|
|
color: AppColors.primaryDark,
|
|
),
|
|
),
|
|
title: Text(
|
|
name,
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
subtitle: Text(
|
|
'$title · $dept',
|
|
style: const TextStyle(
|
|
fontSize: 13,
|
|
color: AppColors.textSecondary,
|
|
),
|
|
),
|
|
trailing: _selectedDoctorId == d['id']?.toString()
|
|
? const Icon(
|
|
LucideIcons.circleCheck,
|
|
size: 22,
|
|
color: AppColors.primaryDark,
|
|
)
|
|
: null,
|
|
onTap: () {
|
|
setState(() {
|
|
_selectedDoctorId = d['id']?.toString();
|
|
_selectedDoctorName = name;
|
|
});
|
|
Navigator.pop(ctx);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
loading: () => const SizedBox(
|
|
height: 200,
|
|
child: Center(child: CircularProgressIndicator()),
|
|
),
|
|
error: (_, _) =>
|
|
const SizedBox(height: 200, child: Center(child: Text('加载失败'))),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
resizeToAvoidBottomInset: false,
|
|
backgroundColor: Colors.white,
|
|
body: Stack(
|
|
children: [
|
|
Positioned.fill(
|
|
child: RepaintBoundary(
|
|
child: Image.asset(
|
|
_loginBg,
|
|
fit: BoxFit.cover,
|
|
alignment: Alignment.topCenter,
|
|
),
|
|
),
|
|
),
|
|
Positioned.fill(
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [
|
|
Colors.white.withValues(alpha: 0.02),
|
|
Colors.white.withValues(alpha: 0.12),
|
|
Colors.white.withValues(alpha: 0.40),
|
|
],
|
|
stops: const [0.0, 0.52, 1.0],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
_LoginKeyboardLift(
|
|
child: SafeArea(
|
|
child: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
return SingleChildScrollView(
|
|
keyboardDismissBehavior:
|
|
ScrollViewKeyboardDismissBehavior.onDrag,
|
|
padding: const EdgeInsets.fromLTRB(20, 22, 20, 24),
|
|
child: ConstrainedBox(
|
|
constraints: BoxConstraints(
|
|
minHeight: (constraints.maxHeight - 52).clamp(
|
|
0.0,
|
|
double.infinity,
|
|
),
|
|
),
|
|
child: Center(
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 430),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
const _LoginHero(),
|
|
const SizedBox(height: 22),
|
|
_LoginFormCard(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (_successMsg != null)
|
|
_NoticeBanner(
|
|
text: _successMsg!,
|
|
success: true,
|
|
),
|
|
if (!_isLogin) ...[
|
|
_DoctorSelector(
|
|
name: _selectedDoctorName,
|
|
onTap: _showDoctorPicker,
|
|
),
|
|
const SizedBox(height: 12),
|
|
_TextField(
|
|
_nameCtrl,
|
|
'姓名',
|
|
TextInputType.name,
|
|
20,
|
|
icon: LucideIcons.userRound,
|
|
),
|
|
const SizedBox(height: 12),
|
|
],
|
|
_TextField(
|
|
_phoneCtrl,
|
|
'手机号',
|
|
TextInputType.phone,
|
|
11,
|
|
icon: LucideIcons.phone,
|
|
),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: _TextField(
|
|
_codeCtrl,
|
|
'验证码',
|
|
TextInputType.number,
|
|
6,
|
|
icon: LucideIcons.messageSquareText,
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
_SmsButton(
|
|
sending: _sending,
|
|
countdown: _countdown,
|
|
onTap: (_countdown > 0 || _sending)
|
|
? null
|
|
: _sendSms,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 14),
|
|
_Agreement(
|
|
agreed: _agreed,
|
|
onTap: () =>
|
|
setState(() => _agreed = !_agreed),
|
|
onTermsTap: () =>
|
|
_openStaticText('terms'),
|
|
onPrivacyTap: () =>
|
|
_openStaticText('privacy'),
|
|
),
|
|
if (_error != null) ...[
|
|
const SizedBox(height: 12),
|
|
_NoticeBanner(
|
|
text: _error!,
|
|
success: false,
|
|
),
|
|
],
|
|
const SizedBox(height: 22),
|
|
_PrimaryButton(
|
|
loading: _loading,
|
|
label: _isLogin ? '登录' : '注册',
|
|
onTap: _loading ? null : _submit,
|
|
),
|
|
if (_isLogin &&
|
|
(Platform.isIOS ||
|
|
Platform.isMacOS)) ...[
|
|
const SizedBox(height: 20),
|
|
SignInWithAppleButton(
|
|
onPressed: _loading
|
|
? () {}
|
|
: _appleSignIn,
|
|
style: SignInWithAppleButtonStyle
|
|
.whiteOutlined,
|
|
height: 48,
|
|
text: '通过 Apple 登录',
|
|
),
|
|
],
|
|
const SizedBox(height: 16),
|
|
GestureDetector(
|
|
onTap: () => setState(() {
|
|
_isLogin = !_isLogin;
|
|
_error = null;
|
|
_successMsg = null;
|
|
}),
|
|
child: Center(
|
|
child: AppGradientText(
|
|
_isLogin ? '没有账号?去注册' : '已有账号?去登录',
|
|
style: const TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _LoginKeyboardLift extends StatelessWidget {
|
|
final Widget child;
|
|
|
|
const _LoginKeyboardLift({required this.child});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final keyboardHeight = MediaQuery.viewInsetsOf(context).bottom;
|
|
final lift = (keyboardHeight * 0.42).clamp(0.0, 150.0);
|
|
return Transform.translate(
|
|
offset: Offset(0, -lift),
|
|
transformHitTests: true,
|
|
child: RepaintBoundary(child: child),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _LoginHero extends StatelessWidget {
|
|
const _LoginHero();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
children: [
|
|
_BrandMark(),
|
|
const SizedBox(height: 12),
|
|
const Text(
|
|
'小脉健康',
|
|
style: TextStyle(
|
|
fontSize: 28,
|
|
fontWeight: FontWeight.w700,
|
|
color: AppColors.textPrimary,
|
|
letterSpacing: 0,
|
|
),
|
|
),
|
|
const SizedBox(height: 6),
|
|
const Text(
|
|
'血管病患者的 AI 健康管理助手',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
height: 1.45,
|
|
fontWeight: FontWeight.w700,
|
|
color: AppColors.textSecondary,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _LoginFormCard extends StatelessWidget {
|
|
final Widget child;
|
|
|
|
const _LoginFormCard({required this.child});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.fromLTRB(18, 18, 18, 20),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withValues(alpha: 0.88),
|
|
borderRadius: AppRadius.xlBorder,
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: const Color(0xFF475467).withValues(alpha: 0.07),
|
|
blurRadius: 24,
|
|
offset: const Offset(0, 12),
|
|
),
|
|
],
|
|
),
|
|
child: child,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _BrandMark extends StatelessWidget {
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return SizedBox(
|
|
width: 116,
|
|
height: 116,
|
|
child: Image.asset(
|
|
'assets/branding/health_login_character_transparent.png',
|
|
fit: BoxFit.contain,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TextField extends StatelessWidget {
|
|
final TextEditingController ctrl;
|
|
final String hint;
|
|
final TextInputType type;
|
|
final int maxLen;
|
|
final IconData? icon;
|
|
|
|
const _TextField(this.ctrl, this.hint, this.type, this.maxLen, {this.icon});
|
|
|
|
@override
|
|
Widget build(BuildContext context) => TextField(
|
|
controller: ctrl,
|
|
keyboardType: type,
|
|
maxLength: maxLen,
|
|
style: const TextStyle(
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w700,
|
|
color: AppColors.textPrimary,
|
|
),
|
|
decoration: InputDecoration(
|
|
hintText: hint,
|
|
counterText: '',
|
|
prefixIcon: icon == null
|
|
? null
|
|
: Icon(icon, size: 20, color: AppColors.primaryDark),
|
|
filled: true,
|
|
fillColor: AppColors.cardInner,
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 15),
|
|
enabledBorder: OutlineInputBorder(
|
|
borderRadius: AppRadius.mdBorder,
|
|
borderSide: BorderSide.none,
|
|
),
|
|
focusedBorder: OutlineInputBorder(
|
|
borderRadius: AppRadius.mdBorder,
|
|
borderSide: const BorderSide(color: AppColors.primary, width: 1.2),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
class _DoctorSelector extends StatelessWidget {
|
|
final String? name;
|
|
final VoidCallback onTap;
|
|
const _DoctorSelector({required this.name, required this.onTap});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return GestureDetector(
|
|
onTap: onTap,
|
|
child: Container(
|
|
height: 50,
|
|
padding: const EdgeInsets.symmetric(horizontal: 14),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.cardInner,
|
|
borderRadius: AppRadius.mdBorder,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
const Icon(
|
|
LucideIcons.stethoscope,
|
|
size: 20,
|
|
color: AppColors.textSecondary,
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
name ?? '请选择健康服务医生(必选)',
|
|
style: TextStyle(
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w700,
|
|
color: name != null
|
|
? AppColors.textPrimary
|
|
: AppColors.textHint,
|
|
),
|
|
),
|
|
),
|
|
const Icon(
|
|
LucideIcons.chevronDown,
|
|
size: 19,
|
|
color: AppColors.textHint,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SmsButton extends StatelessWidget {
|
|
final bool sending;
|
|
final int countdown;
|
|
final VoidCallback? onTap;
|
|
const _SmsButton({
|
|
required this.sending,
|
|
required this.countdown,
|
|
required this.onTap,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final disabled = countdown > 0 || sending;
|
|
return GestureDetector(
|
|
onTap: onTap,
|
|
child: Container(
|
|
width: 108,
|
|
height: 50,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: disabled ? AppColors.cardInner : Colors.white,
|
|
borderRadius: AppRadius.mdBorder,
|
|
border: Border.all(
|
|
color: disabled ? AppColors.borderLight : AppColors.primaryLight,
|
|
),
|
|
),
|
|
child: Text(
|
|
sending
|
|
? '发送中'
|
|
: countdown > 0
|
|
? '${countdown}s'
|
|
: '获取验证码',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w700,
|
|
color: disabled ? AppColors.textSecondary : AppColors.primaryDark,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Agreement extends StatelessWidget {
|
|
final bool agreed;
|
|
final VoidCallback onTap;
|
|
final VoidCallback onTermsTap;
|
|
final VoidCallback onPrivacyTap;
|
|
const _Agreement({
|
|
required this.agreed,
|
|
required this.onTap,
|
|
required this.onTermsTap,
|
|
required this.onPrivacyTap,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
const textStyle = TextStyle(fontSize: 13, color: AppColors.textHint);
|
|
const linkStyle = TextStyle(fontSize: 13, fontWeight: FontWeight.w700);
|
|
|
|
return Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
GestureDetector(
|
|
onTap: onTap,
|
|
behavior: HitTestBehavior.opaque,
|
|
child: Container(
|
|
width: 18,
|
|
height: 18,
|
|
margin: const EdgeInsets.only(right: 7),
|
|
decoration: BoxDecoration(
|
|
gradient: agreed ? _LoginPageState._loginActionGradient : null,
|
|
color: agreed ? null : Colors.white,
|
|
borderRadius: AppRadius.xsBorder,
|
|
border: Border.all(
|
|
color: agreed ? Colors.transparent : AppColors.border,
|
|
),
|
|
),
|
|
child: agreed
|
|
? const Icon(Icons.check, size: 13, color: Colors.white)
|
|
: null,
|
|
),
|
|
),
|
|
Expanded(
|
|
child: Wrap(
|
|
crossAxisAlignment: WrapCrossAlignment.center,
|
|
children: [
|
|
GestureDetector(
|
|
onTap: onTap,
|
|
behavior: HitTestBehavior.opaque,
|
|
child: const Text('已阅读并同意', style: textStyle),
|
|
),
|
|
GestureDetector(
|
|
onTap: onTermsTap,
|
|
behavior: HitTestBehavior.opaque,
|
|
child: const AppGradientText('《服务协议》', style: linkStyle),
|
|
),
|
|
const Text('和', style: textStyle),
|
|
GestureDetector(
|
|
onTap: onPrivacyTap,
|
|
behavior: HitTestBehavior.opaque,
|
|
child: const AppGradientText('《隐私政策》', style: linkStyle),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _NoticeBanner extends StatelessWidget {
|
|
final String text;
|
|
final bool success;
|
|
const _NoticeBanner({required this.text, required this.success});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
width: double.infinity,
|
|
margin: const EdgeInsets.only(bottom: 12),
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
|
decoration: BoxDecoration(
|
|
color: success ? AppColors.successLight : AppColors.errorLight,
|
|
borderRadius: AppRadius.mdBorder,
|
|
border: Border.all(
|
|
color: success
|
|
? AppColors.success.withValues(alpha: 0.16)
|
|
: AppColors.error.withValues(alpha: 0.16),
|
|
),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
success ? Icons.check_circle : Icons.error_outline,
|
|
color: success ? AppColors.success : AppColors.error,
|
|
size: 18,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
text,
|
|
style: TextStyle(
|
|
color: success ? AppColors.success : AppColors.error,
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PrimaryButton extends StatelessWidget {
|
|
final bool loading;
|
|
final String label;
|
|
final VoidCallback? onTap;
|
|
const _PrimaryButton({
|
|
required this.loading,
|
|
required this.label,
|
|
required this.onTap,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return GestureDetector(
|
|
onTap: onTap,
|
|
child: Container(
|
|
width: double.infinity,
|
|
height: 52,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
gradient: _LoginPageState._loginActionGradient,
|
|
borderRadius: AppRadius.mdBorder,
|
|
boxShadow: AppColors.buttonShadow,
|
|
),
|
|
child: loading
|
|
? const SizedBox(
|
|
width: 23,
|
|
height: 23,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2.5,
|
|
color: Colors.white,
|
|
),
|
|
)
|
|
: Text(
|
|
label,
|
|
style: const TextStyle(
|
|
fontSize: 17,
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|