## 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 调整
866 lines
27 KiB
Dart
866 lines
27 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
|
|
|
import '../../core/api_client.dart';
|
|
import '../../core/app_colors.dart';
|
|
import '../../core/app_design_tokens.dart';
|
|
import '../../core/app_module_visuals.dart';
|
|
import '../../core/app_theme.dart';
|
|
import '../../core/navigation_provider.dart';
|
|
import '../../providers/auth_provider.dart';
|
|
import '../../widgets/app_error_state.dart';
|
|
import '../../widgets/app_toast.dart';
|
|
import 'notification_prefs_logic.dart';
|
|
|
|
final notificationPrefsProvider =
|
|
NotifierProvider<NotificationPrefsNotifier, NotificationPrefsViewState>(
|
|
NotificationPrefsNotifier.new,
|
|
);
|
|
|
|
class NotificationPrefsNotifier extends Notifier<NotificationPrefsViewState> {
|
|
@override
|
|
NotificationPrefsViewState build() {
|
|
Future.microtask(load);
|
|
return const NotificationPrefsViewState(loading: true);
|
|
}
|
|
|
|
Future<void> load() async {
|
|
state = state.copyWith(loading: true, clearLoadError: true);
|
|
try {
|
|
final response = await ref
|
|
.read(apiClientProvider)
|
|
.get('/api/notification-prefs');
|
|
final data = response.data['data'];
|
|
if (data is! Map) throw const ApiException('服务器返回的通知设置无效');
|
|
state = NotificationPrefsViewState(
|
|
prefs: NotificationPrefs.fromJson(data),
|
|
);
|
|
} catch (error) {
|
|
state = NotificationPrefsViewState(loadError: _message(error));
|
|
}
|
|
}
|
|
|
|
Future<String?> updateBool({
|
|
required String operationKey,
|
|
required String backendField,
|
|
required bool value,
|
|
}) => _save(operationKey, {backendField: value});
|
|
|
|
Future<String?> setDndStart(TimeOfDay time) =>
|
|
_save('dndStart', {'dndStartMinutes': time.hour * 60 + time.minute});
|
|
|
|
Future<String?> setDndEnd(TimeOfDay time) =>
|
|
_save('dndEnd', {'dndEndMinutes': time.hour * 60 + time.minute});
|
|
|
|
Future<String?> saveHealthMetrics(Set<HealthReminderMetric> metrics) {
|
|
if (metrics.isEmpty) return Future.value('至少选择一项提醒指标');
|
|
return _save('healthMetrics', healthMetricUpdatePayload(metrics));
|
|
}
|
|
|
|
Future<String?> _save(
|
|
String operationKey,
|
|
Map<String, dynamic> payload,
|
|
) async {
|
|
if (state.prefs == null || state.savingKeys.contains(operationKey)) {
|
|
return null;
|
|
}
|
|
final saving = {...state.savingKeys, operationKey};
|
|
state = state.copyWith(savingKeys: saving);
|
|
try {
|
|
final response = await ref
|
|
.read(apiClientProvider)
|
|
.put('/api/notification-prefs', data: payload);
|
|
final data = response.data['data'];
|
|
if (data is! Map) throw const ApiException('服务器未返回最新通知设置');
|
|
state = NotificationPrefsViewState(
|
|
prefs: NotificationPrefs.fromJson(data),
|
|
savingKeys: {...saving}..remove(operationKey),
|
|
);
|
|
return null;
|
|
} catch (error) {
|
|
state = state.copyWith(savingKeys: {...saving}..remove(operationKey));
|
|
return _message(error);
|
|
}
|
|
}
|
|
|
|
String _message(Object error) =>
|
|
error is ApiException ? error.message : '保存失败,请检查网络后重试';
|
|
}
|
|
|
|
class NotificationPrefsPage extends ConsumerWidget {
|
|
const NotificationPrefsPage({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final viewState = ref.watch(notificationPrefsProvider);
|
|
|
|
return GradientScaffold(
|
|
appBar: AppBar(
|
|
backgroundColor: Colors.white,
|
|
elevation: 0,
|
|
leading: IconButton(
|
|
icon: const Icon(
|
|
Icons.arrow_back_ios_new,
|
|
color: AppColors.textPrimary,
|
|
),
|
|
onPressed: () => popRoute(ref),
|
|
),
|
|
title: const Text(
|
|
'消息通知',
|
|
style: TextStyle(
|
|
fontSize: 21,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppColors.textPrimary,
|
|
),
|
|
),
|
|
centerTitle: true,
|
|
),
|
|
body: viewState.loading
|
|
? const Center(child: CircularProgressIndicator(strokeWidth: 2))
|
|
: viewState.loadError != null
|
|
? AppErrorState(
|
|
title: '通知设置加载失败',
|
|
subtitle: viewState.loadError!,
|
|
onRetry: () =>
|
|
ref.read(notificationPrefsProvider.notifier).load(),
|
|
)
|
|
: _PreferencesContent(
|
|
prefs: viewState.prefs!,
|
|
savingKeys: viewState.savingKeys,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PreferencesContent extends ConsumerWidget {
|
|
final NotificationPrefs prefs;
|
|
final Set<String> savingKeys;
|
|
|
|
const _PreferencesContent({required this.prefs, required this.savingKeys});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final notifier = ref.read(notificationPrefsProvider.notifier);
|
|
String formatMinutes(int value) =>
|
|
'${(value ~/ 60).toString().padLeft(2, '0')}:${(value % 60).toString().padLeft(2, '0')}';
|
|
|
|
Future<void> save(Future<String?> operation) async {
|
|
final error = await operation;
|
|
if (error != null && context.mounted) {
|
|
AppToast.show(context, error, type: AppToastType.error);
|
|
}
|
|
}
|
|
|
|
return SingleChildScrollView(
|
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_SettingsListSection(
|
|
title: '推送通知',
|
|
children: [
|
|
_SwitchTile(
|
|
title: '允许推送通知',
|
|
subtitle: '关闭后将不再收到任何系统通知',
|
|
value: prefs.pushEnabled,
|
|
saving: savingKeys.contains('pushEnabled'),
|
|
showDivider: false,
|
|
onChanged: savingKeys.contains('pushEnabled')
|
|
? null
|
|
: (value) => save(
|
|
notifier.updateBool(
|
|
operationKey: 'pushEnabled',
|
|
backendField: 'pushEnabled',
|
|
value: value,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 24),
|
|
_SettingsListSection(
|
|
title: '通知类型',
|
|
children: [
|
|
_SwitchTile(
|
|
icon: AppModuleVisuals.medication.icon,
|
|
iconBg: AppModuleVisuals.medication.lightColor,
|
|
iconColor: AppModuleVisuals.medication.color,
|
|
title: '用药提醒',
|
|
subtitle: '服药时间到达时提醒您',
|
|
value: prefs.medicationReminder,
|
|
saving: savingKeys.contains('medication'),
|
|
onChanged: savingKeys.contains('medication')
|
|
? null
|
|
: (value) => save(
|
|
notifier.updateBool(
|
|
operationKey: 'medication',
|
|
backendField: 'medicationReminder',
|
|
value: value,
|
|
),
|
|
),
|
|
),
|
|
_SwitchTile(
|
|
icon: LucideIcons.triangleAlert,
|
|
iconBg: AppModuleVisuals.health.lightColor,
|
|
iconColor: AppModuleVisuals.health.color,
|
|
title: '健康异常提醒',
|
|
subtitle: '检测到数据异常时及时通知',
|
|
value: prefs.abnormalAlert,
|
|
saving: savingKeys.contains('healthAlert'),
|
|
onChanged: savingKeys.contains('healthAlert')
|
|
? null
|
|
: (value) => save(
|
|
notifier.updateBool(
|
|
operationKey: 'healthAlert',
|
|
backendField: 'abnormalAlert',
|
|
value: value,
|
|
),
|
|
),
|
|
),
|
|
_SwitchTile(
|
|
icon: AppModuleVisuals.followup.icon,
|
|
iconBg: AppModuleVisuals.followup.lightColor,
|
|
iconColor: AppModuleVisuals.followup.color,
|
|
title: '复查日期提醒',
|
|
subtitle: '复查日前提醒您安排就医',
|
|
value: prefs.followUpReminder,
|
|
saving: savingKeys.contains('followUp'),
|
|
onChanged: savingKeys.contains('followUp')
|
|
? null
|
|
: (value) => save(
|
|
notifier.updateBool(
|
|
operationKey: 'followUp',
|
|
backendField: 'followUpReminder',
|
|
value: value,
|
|
),
|
|
),
|
|
),
|
|
_SwitchTile(
|
|
icon: AppModuleVisuals.ai.icon,
|
|
iconBg: AppModuleVisuals.ai.lightColor,
|
|
iconColor: AppModuleVisuals.ai.color,
|
|
title: 'AI 回复通知',
|
|
subtitle: 'AI 助手回复时发送通知',
|
|
value: prefs.doctorReply,
|
|
saving: savingKeys.contains('aiReply'),
|
|
showDivider: false,
|
|
onChanged: savingKeys.contains('aiReply')
|
|
? null
|
|
: (value) => save(
|
|
notifier.updateBool(
|
|
operationKey: 'aiReply',
|
|
backendField: 'doctorReply',
|
|
value: value,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 24),
|
|
_SettingsListSection(
|
|
title: '健康录入提醒',
|
|
children: [
|
|
_SwitchTile(
|
|
icon: LucideIcons.alarmClock,
|
|
iconBg: AppModuleVisuals.health.lightColor,
|
|
iconColor: AppModuleVisuals.health.color,
|
|
title: '每日录入提醒',
|
|
subtitle: '仅提醒当天尚未录入的健康指标',
|
|
value: prefs.healthRecordReminder,
|
|
saving: savingKeys.contains('healthRecord'),
|
|
onChanged: savingKeys.contains('healthRecord')
|
|
? null
|
|
: (value) => save(
|
|
notifier.updateBool(
|
|
operationKey: 'healthRecord',
|
|
backendField: 'healthRecordReminder',
|
|
value: value,
|
|
),
|
|
),
|
|
),
|
|
_ActionTile(
|
|
icon: LucideIcons.slidersHorizontal,
|
|
title: '提醒指标',
|
|
subtitle: healthMetricSummary(prefs.enabledHealthMetrics),
|
|
enabled:
|
|
prefs.healthRecordReminder &&
|
|
!savingKeys.contains('healthMetrics'),
|
|
saving: savingKeys.contains('healthMetrics'),
|
|
onTap: () => _showHealthMetricSheet(
|
|
context,
|
|
prefs.enabledHealthMetrics,
|
|
notifier.saveHealthMetrics,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 24),
|
|
_SettingsListSection(
|
|
title: '免打扰时段',
|
|
children: [
|
|
_SwitchTile(
|
|
title: '开启免打扰模式',
|
|
subtitle: prefs.dndEnabled
|
|
? '${formatMinutes(prefs.dndStartMinutes)} - ${formatMinutes(prefs.dndEndMinutes)} 期间静音'
|
|
: '关闭后全天接收通知',
|
|
value: prefs.dndEnabled,
|
|
saving: savingKeys.contains('dndEnabled'),
|
|
showDivider: prefs.dndEnabled,
|
|
onChanged: savingKeys.contains('dndEnabled')
|
|
? null
|
|
: (value) => save(
|
|
notifier.updateBool(
|
|
operationKey: 'dndEnabled',
|
|
backendField: 'dndEnabled',
|
|
value: value,
|
|
),
|
|
),
|
|
),
|
|
if (prefs.dndEnabled)
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 14,
|
|
),
|
|
color: AppTheme.surface,
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: _TimeButton(
|
|
label: '开始',
|
|
time: formatMinutes(prefs.dndStartMinutes),
|
|
saving: savingKeys.contains('dndStart'),
|
|
onTap: () async {
|
|
final picked = await showAppTimePicker(
|
|
context,
|
|
initialTime: TimeOfDay(
|
|
hour: prefs.dndStartMinutes ~/ 60,
|
|
minute: prefs.dndStartMinutes % 60,
|
|
),
|
|
);
|
|
if (picked != null && context.mounted) {
|
|
await save(notifier.setDndStart(picked));
|
|
}
|
|
},
|
|
),
|
|
),
|
|
const Padding(
|
|
padding: EdgeInsets.symmetric(horizontal: 12),
|
|
child: Text(
|
|
'~',
|
|
style: TextStyle(
|
|
fontSize: 19,
|
|
color: AppColors.textHint,
|
|
),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: _TimeButton(
|
|
label: '结束',
|
|
time: formatMinutes(prefs.dndEndMinutes),
|
|
saving: savingKeys.contains('dndEnd'),
|
|
onTap: () async {
|
|
final picked = await showAppTimePicker(
|
|
context,
|
|
initialTime: TimeOfDay(
|
|
hour: prefs.dndEndMinutes ~/ 60,
|
|
minute: prefs.dndEndMinutes % 60,
|
|
),
|
|
);
|
|
if (picked != null && context.mounted) {
|
|
await save(notifier.setDndEnd(picked));
|
|
}
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 40),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _showHealthMetricSheet(
|
|
BuildContext context,
|
|
Set<HealthReminderMetric> selected,
|
|
Future<String?> Function(Set<HealthReminderMetric>) onSave,
|
|
) => showModalBottomSheet<void>(
|
|
context: context,
|
|
useSafeArea: true,
|
|
isScrollControlled: true,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (context) => _HealthMetricSheet(selected: selected, onSave: onSave),
|
|
);
|
|
|
|
class _HealthMetricSheet extends StatefulWidget {
|
|
final Set<HealthReminderMetric> selected;
|
|
final Future<String?> Function(Set<HealthReminderMetric>) onSave;
|
|
|
|
const _HealthMetricSheet({required this.selected, required this.onSave});
|
|
|
|
@override
|
|
State<_HealthMetricSheet> createState() => _HealthMetricSheetState();
|
|
}
|
|
|
|
class _HealthMetricSheetState extends State<_HealthMetricSheet> {
|
|
late final Set<HealthReminderMetric> _draft = {...widget.selected};
|
|
bool _saving = false;
|
|
String? _error;
|
|
|
|
Future<void> _save() async {
|
|
if (_draft.isEmpty) {
|
|
setState(() => _error = '至少选择一项提醒指标');
|
|
return;
|
|
}
|
|
setState(() {
|
|
_saving = true;
|
|
_error = null;
|
|
});
|
|
final error = await widget.onSave(_draft);
|
|
if (!mounted) return;
|
|
if (error == null) {
|
|
Navigator.pop(context);
|
|
return;
|
|
}
|
|
setState(() {
|
|
_saving = false;
|
|
_error = error;
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Container(
|
|
padding: EdgeInsets.fromLTRB(
|
|
20,
|
|
12,
|
|
20,
|
|
18 + MediaQuery.viewInsetsOf(context).bottom,
|
|
),
|
|
decoration: const BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Center(
|
|
child: Container(
|
|
width: 38,
|
|
height: 4,
|
|
decoration: BoxDecoration(
|
|
color: AppColors.border,
|
|
borderRadius: AppRadius.pillBorder,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 18),
|
|
const Text(
|
|
'选择提醒指标',
|
|
style: TextStyle(
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppColors.textPrimary,
|
|
),
|
|
),
|
|
const SizedBox(height: 5),
|
|
const Text(
|
|
'当天未录入已选指标时,小脉会发送提醒。',
|
|
style: TextStyle(fontSize: 13, color: AppColors.textSecondary),
|
|
),
|
|
const SizedBox(height: 14),
|
|
for (final metric in HealthReminderMetric.values)
|
|
_MetricCheckbox(
|
|
metric: metric,
|
|
selected: _draft.contains(metric),
|
|
enabled: !_saving,
|
|
onChanged: (selected) {
|
|
setState(() {
|
|
selected ? _draft.add(metric) : _draft.remove(metric);
|
|
_error = null;
|
|
});
|
|
},
|
|
),
|
|
if (_error != null) ...[
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
_error!,
|
|
style: const TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w700,
|
|
color: AppColors.error,
|
|
),
|
|
),
|
|
],
|
|
const SizedBox(height: 18),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: FilledButton(
|
|
onPressed: _saving ? null : _save,
|
|
child: Text(_saving ? '保存中...' : '保存'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
class _MetricCheckbox extends StatelessWidget {
|
|
final HealthReminderMetric metric;
|
|
final bool selected;
|
|
final bool enabled;
|
|
final ValueChanged<bool> onChanged;
|
|
|
|
const _MetricCheckbox({
|
|
required this.metric,
|
|
required this.selected,
|
|
required this.enabled,
|
|
required this.onChanged,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) => InkWell(
|
|
onTap: enabled ? () => onChanged(!selected) : null,
|
|
borderRadius: AppRadius.mdBorder,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 7),
|
|
child: Row(
|
|
children: [
|
|
Checkbox(
|
|
value: selected,
|
|
onChanged: enabled ? (value) => onChanged(value ?? false) : null,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
metric.label,
|
|
style: const TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w700,
|
|
color: AppColors.textPrimary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
class _SectionTitle extends StatelessWidget {
|
|
final String title;
|
|
const _SectionTitle({required this.title});
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Padding(
|
|
padding: const EdgeInsets.only(left: 4, bottom: 10),
|
|
child: Text(
|
|
title,
|
|
style: const TextStyle(
|
|
fontSize: 17,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppColors.textSecondary,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
class _SettingsListSection extends StatelessWidget {
|
|
final String title;
|
|
final List<Widget> children;
|
|
|
|
const _SettingsListSection({required this.title, required this.children});
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_SectionTitle(title: title),
|
|
ClipRRect(
|
|
borderRadius: AppRadius.mdBorder,
|
|
child: ColoredBox(
|
|
color: AppTheme.surface,
|
|
child: Column(children: children),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
class _SwitchTile extends StatelessWidget {
|
|
final IconData? icon;
|
|
final Color? iconBg;
|
|
final Color? iconColor;
|
|
final String title;
|
|
final String? subtitle;
|
|
final bool value;
|
|
final bool saving;
|
|
final bool showDivider;
|
|
final ValueChanged<bool>? onChanged;
|
|
|
|
const _SwitchTile({
|
|
this.icon,
|
|
this.iconBg,
|
|
this.iconColor,
|
|
required this.title,
|
|
this.subtitle,
|
|
required this.value,
|
|
this.saving = false,
|
|
this.showDivider = true,
|
|
required this.onChanged,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) => _SettingsRow(
|
|
icon: icon,
|
|
iconBg: iconBg,
|
|
iconColor: iconColor,
|
|
title: title,
|
|
subtitle: subtitle,
|
|
showDivider: showDivider,
|
|
trailing: saving
|
|
? const SizedBox(
|
|
width: 22,
|
|
height: 22,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: _AppSwitch(value: value, onChanged: onChanged),
|
|
);
|
|
}
|
|
|
|
class _ActionTile extends StatelessWidget {
|
|
final IconData icon;
|
|
final String title;
|
|
final String subtitle;
|
|
final bool enabled;
|
|
final bool saving;
|
|
final VoidCallback onTap;
|
|
|
|
const _ActionTile({
|
|
required this.icon,
|
|
required this.title,
|
|
required this.subtitle,
|
|
required this.enabled,
|
|
required this.saving,
|
|
required this.onTap,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Material(
|
|
color: Colors.transparent,
|
|
child: InkWell(
|
|
onTap: enabled ? onTap : null,
|
|
child: _SettingsRow(
|
|
icon: icon,
|
|
iconBg: AppColors.iconBg,
|
|
iconColor: AppColors.primary,
|
|
title: title,
|
|
subtitle: subtitle,
|
|
showDivider: false,
|
|
muted: !enabled,
|
|
trailing: saving
|
|
? const SizedBox(
|
|
width: 22,
|
|
height: 22,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Icon(
|
|
Icons.chevron_right_rounded,
|
|
color: AppColors.textHint,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
class _SettingsRow extends StatelessWidget {
|
|
final IconData? icon;
|
|
final Color? iconBg;
|
|
final Color? iconColor;
|
|
final String title;
|
|
final String? subtitle;
|
|
final Widget trailing;
|
|
final bool showDivider;
|
|
final bool muted;
|
|
|
|
const _SettingsRow({
|
|
this.icon,
|
|
this.iconBg,
|
|
this.iconColor,
|
|
required this.title,
|
|
this.subtitle,
|
|
required this.trailing,
|
|
required this.showDivider,
|
|
this.muted = false,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Opacity(
|
|
opacity: muted ? 0.48 : 1,
|
|
child: Column(
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
|
|
child: Row(
|
|
children: [
|
|
if (icon != null) ...[
|
|
Container(
|
|
width: 34,
|
|
height: 34,
|
|
decoration: BoxDecoration(
|
|
color: iconBg ?? AppColors.iconBg,
|
|
borderRadius: AppRadius.smBorder,
|
|
),
|
|
child: Icon(
|
|
icon,
|
|
size: 20,
|
|
color: iconColor ?? AppColors.primary,
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
],
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
title,
|
|
style: const TextStyle(
|
|
fontSize: 16,
|
|
color: AppColors.textPrimary,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
if (subtitle?.isNotEmpty == true) ...[
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
subtitle!,
|
|
style: const TextStyle(
|
|
fontSize: 13,
|
|
color: AppColors.textSecondary,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
trailing,
|
|
],
|
|
),
|
|
),
|
|
if (showDivider)
|
|
Padding(
|
|
padding: EdgeInsets.only(left: icon != null ? 60 : 14),
|
|
child: const Divider(
|
|
height: 1,
|
|
thickness: 0.7,
|
|
color: Color(0xFFE8ECF2),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
class _AppSwitch extends StatelessWidget {
|
|
final bool value;
|
|
final ValueChanged<bool>? onChanged;
|
|
|
|
const _AppSwitch({required this.value, required this.onChanged});
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Semantics(
|
|
button: true,
|
|
toggled: value,
|
|
enabled: onChanged != null,
|
|
child: GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: onChanged == null ? null : () => onChanged!(!value),
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 160),
|
|
curve: Curves.easeOutCubic,
|
|
width: 46,
|
|
height: 28,
|
|
padding: const EdgeInsets.all(3),
|
|
decoration: BoxDecoration(
|
|
color: value ? AppColors.primary : Colors.white,
|
|
borderRadius: AppRadius.pillBorder,
|
|
border: Border.all(
|
|
color: value
|
|
? AppColors.primary.withValues(alpha: 0.38)
|
|
: AppColors.border,
|
|
),
|
|
),
|
|
child: AnimatedAlign(
|
|
duration: const Duration(milliseconds: 160),
|
|
curve: Curves.easeOutCubic,
|
|
alignment: value ? Alignment.centerRight : Alignment.centerLeft,
|
|
child: Container(
|
|
width: 20,
|
|
height: 20,
|
|
decoration: BoxDecoration(
|
|
color: value ? Colors.white : AppColors.textHint,
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
class _TimeButton extends StatelessWidget {
|
|
final String label;
|
|
final String time;
|
|
final bool saving;
|
|
final VoidCallback onTap;
|
|
|
|
const _TimeButton({
|
|
required this.label,
|
|
required this.time,
|
|
required this.saving,
|
|
required this.onTap,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) => InkWell(
|
|
onTap: saving ? null : onTap,
|
|
borderRadius: AppRadius.mdBorder,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
|
decoration: BoxDecoration(
|
|
border: Border.all(color: AppColors.border),
|
|
borderRadius: AppRadius.mdBorder,
|
|
),
|
|
child: Column(
|
|
children: [
|
|
Text(
|
|
label,
|
|
style: const TextStyle(fontSize: 14, color: AppColors.textHint),
|
|
),
|
|
if (saving)
|
|
const SizedBox(
|
|
width: 20,
|
|
height: 20,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
else
|
|
Text(
|
|
time,
|
|
style: const TextStyle(
|
|
fontSize: 19,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppTheme.primary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|