基本完成

This commit is contained in:
zhutao
2025-08-28 16:27:56 +08:00
commit 5d7d233d2e
132 changed files with 6390 additions and 0 deletions

View File

@@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
import 'package:food_health/api/dto/user_profile_dto.dart';
class SelectionState extends ChangeNotifier {
UserProfileDto userProfile = UserProfileDto();
SelectionState(this.userProfile);
void update(void Function(UserProfileDto) updater) {
updater(userProfile);
notifyListeners();
}
}
class SelectionProvider extends InheritedNotifier<SelectionState> {
const SelectionProvider({
super.key,
required SelectionState super.notifier,
required super.child,
});
static SelectionState of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<SelectionProvider>()!.notifier!;
}
}

View File

@@ -0,0 +1,292 @@
import 'package:flutter/material.dart';
import 'package:flutter_easyloading/flutter_easyloading.dart';
import 'package:food_health/api/dto/profile_options_dto.dart';
import 'package:food_health/api/dto/user_profile_dto.dart';
import 'package:food_health/api/endpoints/profile_api.dart';
import 'package:food_health/config/theme/custom_colors.dart';
import 'package:food_health/page/profile/edit/widget/food_allergies.dart';
import 'package:food_health/widgets/common/app_backend.dart';
import 'package:go_router/go_router.dart';
import 'package:remixicon/remixicon.dart';
import 'data/state.dart';
import 'widget/dietary_preferences.dart';
import 'widget/health_profile.dart';
class MyEditPage extends StatefulWidget {
final UserProfileDto userProfile;
const MyEditPage({super.key, required this.userProfile});
@override
State<MyEditPage> createState() => _MyEditPageState();
}
class _MyEditPageState extends State<MyEditPage> {
late SelectionState selectionState;
List<ProfileOptionDto> _options = [];
var _loading = true;
///步骤
var _step = 0;
var stepList = [
StepItem(
title: "Food Allergies",
icon: RemixIcons.shield_line,
subTitle: "Help us keep you safe by telling us about your allergies",
),
StepItem(
title: "Dietary Preferences",
icon: RemixIcons.heart_line,
subTitle: "What dietary restrictions or preferences do you follow?",
),
StepItem(
title: "Health Profile",
icon: RemixIcons.user_line,
subTitle: "Share relevant health information for personalized recommendations",
),
];
@override
void initState() {
super.initState();
_init();
}
void _init() async {
selectionState = SelectionState(widget.userProfile);
var res = await getProfileOptionsApi();
setState(() {
_options = res;
_loading = false;
});
}
///设置步骤
void _handStep(bool isNext) {
if (_step == 2 && isNext) {
_submit();
return;
}
setState(() {
_step = (_step + (isNext ? 1 : -1)).clamp(0, 2);
});
}
void _submit() async {
EasyLoading.show(
status: 'Saving…',
maskType: EasyLoadingMaskType.clear,
);
await updateProfileApi(selectionState.userProfile);
EasyLoading.dismiss();
context.pop();
}
@override
Widget build(BuildContext context) {
if (_loading) {
return Center(child: CircularProgressIndicator());
}
return Scaffold(
body: AppBackend(
child: ListView(
padding: EdgeInsets.only(top: 30),
children: [
buildHeader(),
buildStep(),
buildStepInfo(),
SelectionProvider(
notifier: selectionState,
child: Builder(
builder: (context) {
if (_step == 0) {
return FoodAllergies(
options: _options,
);
} else if (_step == 1) {
return DietaryPreferences(
options: _options,
);
} else if (_step == 2) {
return HealthProfile(
options: _options,
);
}
return SizedBox();
},
),
),
Container(
margin: EdgeInsets.only(top: 30),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Opacity(
opacity: _step == 0 ? 0.4 : 1,
child: buildItemButton(
title: "Previous",
color: Colors.black,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
),
onTap: () {
_handStep(false);
},
),
),
buildItemButton(
title: _step == 2 ? "Complete Setup" : "Continue",
decoration: BoxDecoration(
color: _step == 2 ? Theme.of(context).colorScheme.success : null,
gradient: _step == 2
? null
: LinearGradient(
colors: [
Theme.of(context).colorScheme.primary,
Theme.of(context).colorScheme.primaryEnd,
],
),
),
onTap: () {
_handStep(true);
},
),
],
),
),
],
),
),
);
}
///构建顶部
Widget buildHeader() {
return Column(
children: [
Text(
"Welcome to FoodSafe",
style: Theme.of(context).textTheme.titleLarge,
),
Container(
margin: EdgeInsets.only(top: 5),
child: Text(
"Let's personalize your food safety experience",
style: Theme.of(context).textTheme.labelMedium,
),
),
],
);
}
///步骤条
Widget buildStep() {
return Container(
margin: EdgeInsets.only(top: 20),
child: Row(
spacing: 10,
children: stepList.asMap().entries.map((entre) {
//数据
var item = entre.value;
var index = entre.key;
var isLast = index == stepList.length - 1;
//颜色
var selectColor = Theme.of(context).colorScheme.primary;
var unselectedColor = Theme.of(context).colorScheme.surfaceContainerHigh;
return Expanded(
flex: isLast ? 0 : 1,
child: Row(
spacing: 10,
children: [
Container(
width: 50,
height: 50,
decoration: BoxDecoration(
color: _step >= index ? selectColor : unselectedColor,
shape: BoxShape.circle,
),
child: Icon(
item.icon,
color: _step >= index ? Colors.white : Color(0xff9ca3af),
),
),
Visibility(
visible: !isLast,
child: Expanded(
child: Container(
height: 3,
color: _step > index ? selectColor : unselectedColor,
),
),
),
],
),
);
}).toList(),
),
);
}
///步骤条信息
Widget buildStepInfo() {
var stepInfo = stepList[_step];
return Container(
margin: EdgeInsets.only(top: 20, bottom: 30),
child: Column(
children: [
Text(
stepInfo.title,
style: Theme.of(context).textTheme.titleMedium,
),
Container(
margin: EdgeInsets.only(top: 5),
child: Text(
stepInfo.subTitle,
style: Theme.of(context).textTheme.labelMedium,
textAlign: TextAlign.center,
),
),
],
),
);
}
///item按钮
Widget buildItemButton({
required String title,
Color color = Colors.white,
required BoxDecoration decoration,
required Function() onTap,
}) {
return InkWell(
onTap: onTap,
child: Container(
padding: EdgeInsets.symmetric(vertical: 10, horizontal: 20),
decoration: decoration.copyWith(
borderRadius: BorderRadius.circular(8),
),
child: Text(
title,
style: TextStyle(color: color),
),
),
);
}
}
class StepItem {
final IconData icon;
final String title;
final String subTitle;
StepItem({
required this.icon,
required this.title,
required this.subTitle,
});
}

View File

@@ -0,0 +1,9 @@
import 'package:food_health/api/dto/profile_options_dto.dart';
List<String> getOptions(List<ProfileOptionDto> options, String key) {
var data = options.firstWhere((item) {
return item.key == key;
});
//
return data.valuesList ?? [];
}

View File

@@ -0,0 +1,100 @@
import 'dart:ffi';
import 'package:flutter/material.dart';
///步骤内容卡片
class StepContentCard extends StatelessWidget {
final List<Widget> children;
const StepContentCard({super.key, required this.children});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(15),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Theme.of(context).colorScheme.shadow,
blurRadius: 7,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: children,
),
);
}
}
///卡片标题
class CardTitle extends StatelessWidget {
final String title;
const CardTitle({super.key, required this.title});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 15),
child: Text(
title,
style: Theme.of(context).textTheme.titleSmall,
),
);
}
}
///配置列表
class OptionList extends StatelessWidget {
final List<String> options;
final List<String> selects;
final double widthFactor;
final Function(String) onTap;
const OptionList({
super.key,
required this.options,
required this.selects,
this.widthFactor = 0.5,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Wrap(
runSpacing: 20,
children: options.map((item) {
return FractionallySizedBox(
widthFactor: widthFactor,
child: InkWell(
onTap: () {
onTap(item);
},
child: Row(
spacing: 5,
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 20,
height: 20,
child: Checkbox(
value: selects.contains(item),
onChanged: (_) {
onTap(item);
},
),
),
Text(item),
],
),
),
);
}).toList(),
);
}
}

View File

@@ -0,0 +1,127 @@
import 'package:flutter/material.dart';
import 'package:food_health/api/dto/profile_options_dto.dart';
import '../data/state.dart';
import '../util/common.dart';
import 'common.dart';
class DietaryPreferences extends StatefulWidget {
final List<ProfileOptionDto> options;
const DietaryPreferences({super.key, required this.options});
@override
State<DietaryPreferences> createState() => _DietaryPreferencesState();
}
class _DietaryPreferencesState extends State<DietaryPreferences> {
///切换标签
void _handToggle(String tag) {
var state = SelectionProvider.of(context);
if (getIsSelect(tag)) {
state.update((p) => p.dietaryPreferencesList.remove(tag));
} else {
state.update((p) => p.dietaryPreferencesList.add(tag));
}
}
///选中年龄
void _handAgeRange(String tag) {
var state = SelectionProvider.of(context);
state.update((p) => p.ageRange = tag);
}
///等级
void _handActivityLevel(String tag) {
var state = SelectionProvider.of(context);
state.update((p) => p.activityLevel = tag);
}
///是否标签选中
bool getIsSelect(String tag) {
var state = SelectionProvider.of(context);
return state.userProfile.dietaryPreferencesList.contains(tag);
}
@override
Widget build(BuildContext context) {
var state = SelectionProvider.of(context);
return StepContentCard(
children: [
CardTitle(title: "Dietary Restrictions & Preferences"),
Container(
margin: EdgeInsets.only(bottom: 15),
child: OptionList(
options: getOptions(widget.options, "dietary_restrictions"),
selects: state.userProfile.dietaryPreferencesList,
onTap: _handToggle,
),
),
CardTitle(title: "Age Range"),
Container(
margin: EdgeInsets.only(bottom: 15),
child: RadioGroup(
options: getOptions(widget.options, "age_ranges"),
value: state.userProfile.ageRange,
onChanged: _handAgeRange,
),
),
CardTitle(title: "Activity Level"),
RadioGroup(
options: getOptions(widget.options, "activity_levels"),
value: state.userProfile.activityLevel,
onChanged: _handActivityLevel,
),
],
);
}
}
///单选列表
class RadioGroup extends StatelessWidget {
final List<String> options;
final String value;
final int crossAxisCount;
final Function(String) onChanged;
const RadioGroup({
super.key,
required this.options,
required this.value,
this.crossAxisCount = 3,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisSpacing: 10,
mainAxisSpacing: 10,
crossAxisCount: crossAxisCount,
mainAxisExtent: 40,
),
itemBuilder: (context, index) {
var data = options[index];
var isSelected = value == data;
return InkWell(
onTap: () {
onChanged(data);
},
child: Container(
alignment: Alignment.center,
decoration: BoxDecoration(
color: isSelected ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: isSelected ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.surfaceContainer),
),
child: Text(data, style: TextStyle(color: isSelected ? Colors.white : Colors.black)),
),
);
},
itemCount: options.length,
);
}
}

View File

@@ -0,0 +1,144 @@
import 'package:flutter/material.dart';
import 'package:food_health/api/dto/profile_options_dto.dart';
import 'package:food_health/config/theme/custom_colors.dart';
import 'package:remixicon/remixicon.dart';
import '../data/state.dart';
import '../util/common.dart';
import 'common.dart';
class FoodAllergies extends StatefulWidget {
final List<ProfileOptionDto> options;
const FoodAllergies({super.key, required this.options});
@override
State<FoodAllergies> createState() => _FoodAllergiesState();
}
class _FoodAllergiesState extends State<FoodAllergies> {
final TextEditingController _otherController = TextEditingController();
@override
void initState() {
super.initState();
}
///切换标签
void _handToggle(String tag) {
var state = SelectionProvider.of(context);
if (getIsSelect(tag)) {
state.update((p) => p.foodAllergiesList.remove(tag));
} else {
state.update((p) => p.foodAllergiesList.add(tag));
}
}
void _handConfirmCustom() {
var state = SelectionProvider.of(context);
if (!getIsSelect(_otherController.text)) {
state.update((p) => p.foodAllergiesList.add(_otherController.text));
_otherController.text = "";
}
}
///是否标签选中
bool getIsSelect(String tag) {
var state = SelectionProvider.of(context);
return state.userProfile.foodAllergiesList.contains(tag);
}
@override
Widget build(BuildContext context) {
var state = SelectionProvider.of(context);
return StepContentCard(
children: [
CardTitle(title: "Common Food Allergies"),
Container(
margin: EdgeInsets.only(bottom: 15),
child: OptionList(
options: getOptions(widget.options, "common_food_allergies"),
selects: state.userProfile.foodAllergiesList,
onTap: _handToggle,
),
),
CardTitle(title: "Other Allergies"),
Container(
margin: EdgeInsets.only(bottom: 15),
child: Row(
spacing: 15,
children: [
Expanded(
child: TextField(
controller: _otherController,
style: Theme.of(context).textTheme.bodyMedium,
decoration: InputDecoration(
isCollapsed: true,
contentPadding: EdgeInsets.symmetric(vertical: 8, horizontal: 16),
hintText: "Add custom allergy...",
filled: true,
fillColor: Theme.of(context).colorScheme.surfaceContainerLow,
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(width: 1, color: Theme.of(context).colorScheme.surfaceContainer),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(width: 1, color: Theme.of(context).colorScheme.surfaceContainer),
),
),
),
),
InkWell(
onTap: _handConfirmCustom,
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(8),
),
child: Icon(RemixIcons.add_fill),
),
),
],
),
),
CardTitle(title: "Your Allergies"),
Wrap(
runSpacing: 10,
spacing: 10,
children: state.userProfile.foodAllergiesList.map((item) {
return InkWell(
onTap: () {
_handToggle(item);
},
child: Container(
padding: EdgeInsets.symmetric(vertical: 3, horizontal: 10),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.danger,
borderRadius: BorderRadius.circular(8),
),
child: Row(
spacing: 5,
mainAxisSize: MainAxisSize.min,
children: [
Text(
item,
style: TextStyle(color: Colors.white, fontSize: 12),
),
Icon(
RemixIcons.close_fill,
color: Colors.white,
size: 20,
),
],
),
),
);
}).toList(),
),
],
);
}
}

View File

@@ -0,0 +1,151 @@
import 'package:flutter/material.dart';
import 'package:food_health/api/dto/profile_options_dto.dart';
import 'package:food_health/config/theme/custom_colors.dart';
import 'package:remixicon/remixicon.dart';
import '../data/state.dart';
import '../util/common.dart';
import 'common.dart';
class HealthProfile extends StatefulWidget {
final List<ProfileOptionDto> options;
const HealthProfile({super.key, required this.options});
@override
State<HealthProfile> createState() => _HealthProfileState();
}
class _HealthProfileState extends State<HealthProfile> {
final TextEditingController _otherController = TextEditingController();
@override
void initState() {
super.initState();
}
///切换标签
void _handToggle(String tag) {
var state = SelectionProvider.of(context);
if (getIsSelect(tag)) {
state.update((p) => p.medicalInformationList.remove(tag));
} else {
state.update((p) => p.medicalInformationList.add(tag));
}
}
///确认搜索内容
void _handConfirmCustom() {
var state = SelectionProvider.of(context);
if (!getIsSelect(_otherController.text)) {
state.update((p) => p.currentMedicationsList.add(_otherController.text));
_otherController.text = "";
}
}
///移除搜索标签
void _handRemoveCustom(String tag) {
var state = SelectionProvider.of(context);
state.update((p) => p.currentMedicationsList.remove(tag));
}
///是否标签选中
bool getIsSelect(String tag) {
var state = SelectionProvider.of(context);
return state.userProfile.medicalInformationList.contains(tag);
}
@override
Widget build(BuildContext context) {
var state = SelectionProvider.of(context);
return StepContentCard(
children: [
CardTitle(title: "Medical Conditions"),
Container(
margin: EdgeInsets.only(bottom: 15),
child: OptionList(
widthFactor: 1,
options: getOptions(widget.options, "medical_conditions"),
selects: state.userProfile.medicalInformationList,
onTap: _handToggle,
),
),
CardTitle(title: "Current Medications"),
Container(
margin: EdgeInsets.only(bottom: 15),
child: Row(
spacing: 15,
children: [
Expanded(
child: TextField(
controller: _otherController,
style: Theme.of(context).textTheme.bodyMedium,
decoration: InputDecoration(
isCollapsed: true,
contentPadding: EdgeInsets.symmetric(vertical: 8, horizontal: 16),
hintText: "Add medication...",
filled: true,
fillColor: Theme.of(context).colorScheme.surfaceContainerLow,
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(width: 1, color: Theme.of(context).colorScheme.surfaceContainer),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(width: 1, color: Theme.of(context).colorScheme.surfaceContainer),
),
),
),
),
InkWell(
onTap: _handConfirmCustom,
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(8),
),
child: Icon(RemixIcons.add_fill),
),
),
],
),
),
Wrap(
runSpacing: 10,
spacing: 10,
children: state.userProfile.currentMedicationsList.map((item) {
return InkWell(
onTap: () {
_handRemoveCustom(item);
},
child: Container(
padding: EdgeInsets.symmetric(vertical: 3, horizontal: 10),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.danger,
borderRadius: BorderRadius.circular(8),
),
child: Row(
spacing: 5,
mainAxisSize: MainAxisSize.min,
children: [
Text(
item,
style: TextStyle(color: Colors.white, fontSize: 12),
),
Icon(
RemixIcons.close_fill,
color: Colors.white,
size: 20,
),
],
),
),
);
}).toList(),
),
],
);
}
}