Flutter for OpenHarmony:从零搭建今日资讯App(二十一)意见反馈功能的完整实现

做应用开发,最怕的就是闭门造车。用户用着不爽,但没地方说;开发者觉得挺好,但不知道问题在哪。意见反馈功能就是连接用户和开发者的桥梁,让用户的声音能传到你耳朵里。

今天这篇文章,咱们就来实现一个完整的意见反馈功能。不只是简单的文本输入框,还要考虑反馈类型分类、表单验证、图片上传、提交状态处理这些实际需求。

意见反馈页面要收集什么信息

在动手写代码之前,先想清楚要收集哪些信息。

反馈类型是必须的。用户的反馈可能是功能建议、Bug报告、内容问题或者其他。分类之后,后台处理起来更方便,也能统计各类反馈的数量。

详细描述是核心内容。用户要能详细说明问题或建议,文本框要够大,让用户能写清楚。

联系方式是可选的。有些用户愿意留下联系方式,方便开发者跟进;有些用户不想暴露隐私,所以这个字段不能强制。

截图或附件在某些场景下很有用。比如用户反馈UI问题,一张截图胜过千言万语。但这个功能实现起来稍复杂,咱们后面再说。

页面的基本结构

先看看项目里意见反馈页面的代码:

dart 复制代码
import 'package:flutter/material.dart';

class FeedbackScreen extends StatefulWidget {
  const FeedbackScreen({super.key});

  @override
  State<FeedbackScreen> createState() => _FeedbackScreenState();
}

StatefulWidget是因为页面有表单状态需要管理:输入框的内容、下拉框的选中值、提交按钮的加载状态等。

dart 复制代码
class _FeedbackScreenState extends State<FeedbackScreen> {
  final _formKey = GlobalKey<FormState>();
  final _feedbackController = TextEditingController();
  final _contactController = TextEditingController();
  String _selectedType = '功能建议';

这里定义了几个关键变量:

_formKey 是表单的key,用于触发表单验证。Flutter的Form组件需要一个GlobalKey来标识,后面调用_formKey.currentState!.validate()就能验证整个表单。

_feedbackController和**_contactController**是两个输入框的控制器。用控制器而不是直接读取输入框的值,是因为控制器可以在任何地方获取或设置输入框的内容,更灵活。

_selectedType存储当前选中的反馈类型,默认是"功能建议"。

别忘了释放资源

dart 复制代码
@override
void dispose() {
  _feedbackController.dispose();
  _contactController.dispose();
  super.dispose();
}

TextEditingController是需要手动释放的资源。如果不在dispose里调用dispose(),会造成内存泄漏。这是Flutter开发的基本规范,养成习惯很重要。

页面布局的实现

整个页面用Form包裹,里面是一个ListView

dart 复制代码
@override
Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(
      title: const Text('意见反馈'),
    ),
    body: Form(
      key: _formKey,
      child: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          // 顶部说明
          // 反馈类型选择
          // 详细描述输入
          // 联系方式输入
          // 提交按钮
        ],
      ),
    ),
  );
}

ListView而不是Column,是因为内容可能超出屏幕高度(特别是键盘弹出时),ListView自带滚动功能。

padding: const EdgeInsets.all(16)给整个列表加边距,内容不贴边,视觉上更舒服。

顶部说明文字

dart 复制代码
const Text(
  '感谢您的反馈!',
  style: TextStyle(
    fontSize: 18,
    fontWeight: FontWeight.bold,
  ),
),
const SizedBox(height: 8),
Text(
  '您的意见对我们非常重要,我们会认真对待每一条反馈。',
  style: TextStyle(
    fontSize: 14,
    color: Colors.grey[600],
  ),
),
const SizedBox(height: 24),

顶部放一段友好的说明文字,让用户感受到被重视。"感谢您的反馈"用大字加粗,表达诚意;下面的说明用小字灰色,不抢眼但能看到。

文案很重要。冷冰冰的"请填写反馈"和热情的"感谢您的反馈",给用户的感觉完全不同。好的文案能提高用户提交反馈的意愿。

反馈类型选择器

DropdownButtonFormField实现下拉选择:

dart 复制代码
DropdownButtonFormField<String>(
  value: _selectedType,
  decoration: const InputDecoration(
    labelText: '反馈类型',
    border: OutlineInputBorder(),
  ),
  items: ['功能建议', 'Bug反馈', '内容问题', '其他']
      .map((type) => DropdownMenuItem(
            value: type,
            child: Text(type),
          ))
      .toList(),
  onChanged: (value) {
    setState(() {
      _selectedType = value!;
    });
  },
),

来拆解一下这段代码:

value 绑定到_selectedType,显示当前选中的值。

decoration 设置输入框的样式。labelText是浮动标签,用户点击前显示在输入框里,点击后浮动到上方。OutlineInputBorder是带边框的样式,比默认的下划线样式更正式。

items 是下拉选项列表。用map把字符串数组转成DropdownMenuItem列表。每个选项的value是实际值,child是显示的内容。

onChanged 是选中值变化时的回调。在回调里调用setState更新_selectedType,下拉框会显示新选中的值。

为什么用DropdownButtonFormField而不是DropdownButton

DropdownButtonFormFieldDropdownButton的Form版本,它能和Form组件配合,支持验证。虽然反馈类型通常不需要验证(因为有默认值),但用FormField系列组件能保持样式一致。

详细描述输入框

dart 复制代码
TextFormField(
  controller: _feedbackController,
  maxLines: 8,
  decoration: const InputDecoration(
    labelText: '详细描述',
    hintText: '请详细描述您的问题或建议...',
    border: OutlineInputBorder(),
  ),
  validator: (value) {
    if (value == null || value.trim().isEmpty) {
      return '请输入反馈内容';
    }
    return null;
  },
),

controller 绑定到_feedbackController,可以随时获取输入的内容。

maxLines: 8让输入框显示8行高度。这是个多行文本框,用户可以输入很多内容。如果不设置maxLines,默认是单行。

labelTexthintText的区别:labelText是浮动标签,始终显示;hintText是占位提示,输入内容后消失。两个都设置,用户体验更好。

validator 是验证函数。当调用_formKey.currentState!.validate()时,会执行这个函数。如果返回非null字符串,表示验证失败,字符串内容会显示为错误提示;返回null表示验证通过。

这里的验证逻辑是:内容不能为空,而且不能只有空格(用trim()去掉首尾空格后判断)。

输入框的字数限制

如果想限制用户输入的字数,可以加上maxLength

dart 复制代码
TextFormField(
  controller: _feedbackController,
  maxLines: 8,
  maxLength: 500,
  decoration: const InputDecoration(
    labelText: '详细描述',
    hintText: '请详细描述您的问题或建议...',
    border: OutlineInputBorder(),
    counterText: '',  // 隐藏默认的字数统计
  ),
  buildCounter: (context, {required currentLength, required isFocused, maxLength}) {
    return Text(
      '$currentLength / $maxLength',
      style: TextStyle(
        color: currentLength > maxLength! * 0.9 ? Colors.orange : Colors.grey,
      ),
    );
  },
),

maxLength: 500限制最多输入500个字符。buildCounter自定义字数统计的显示,当输入超过90%时变成橙色提醒用户。

联系方式输入框

dart 复制代码
TextFormField(
  controller: _contactController,
  decoration: const InputDecoration(
    labelText: '联系方式(可选)',
    hintText: '邮箱或手机号',
    border: OutlineInputBorder(),
  ),
),

联系方式是可选的,所以没有validator。labelText里加了"(可选)",明确告诉用户这个字段不是必填的。

可以加个格式验证

虽然是可选字段,但如果用户填了,最好验证一下格式:

dart 复制代码
TextFormField(
  controller: _contactController,
  decoration: const InputDecoration(
    labelText: '联系方式(可选)',
    hintText: '邮箱或手机号',
    border: OutlineInputBorder(),
  ),
  validator: (value) {
    if (value == null || value.isEmpty) {
      return null;  // 空值允许
    }
    // 简单的邮箱或手机号验证
    final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
    final phoneRegex = RegExp(r'^1[3-9]\d{9}$');
    if (!emailRegex.hasMatch(value) && !phoneRegex.hasMatch(value)) {
      return '请输入有效的邮箱或手机号';
    }
    return null;
  },
),

如果用户填了内容,就验证是否是有效的邮箱或手机号。如果没填,直接通过。

提交按钮

dart 复制代码
ElevatedButton(
  onPressed: _submitFeedback,
  style: ElevatedButton.styleFrom(
    padding: const EdgeInsets.symmetric(vertical: 16),
  ),
  child: const Text('提交反馈'),
),

ElevatedButton是Material Design的凸起按钮,视觉上比较突出,适合主要操作。

padding: const EdgeInsets.symmetric(vertical: 16)让按钮更高,更容易点击。默认的按钮有点矮,在表单底部不够醒目。

提交逻辑的实现

dart 复制代码
void _submitFeedback() {
  if (_formKey.currentState!.validate()) {
    // 验证通过,提交反馈
    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text('提交成功'),
        content: const Text('感谢您的反馈,我们会尽快处理!'),
        actions: [
          TextButton(
            onPressed: () {
              Navigator.pop(context);  // 关闭对话框
              Navigator.pop(context);  // 返回上一页
            },
            child: const Text('确定'),
          ),
        ],
      ),
    );
  }
}

先调用_formKey.currentState!.validate()验证表单。如果验证通过,显示成功对话框。用户点击"确定"后,先关闭对话框,再返回上一页。

两次Navigator.pop:第一次关闭AlertDialog,第二次关闭FeedbackScreen。这是因为showDialog会创建一个新的路由,需要单独pop。

添加提交中的加载状态

实际项目中,提交反馈需要调用后端接口,这个过程可能需要几秒钟。要给用户一个加载状态的反馈:

dart 复制代码
class _FeedbackScreenState extends State<FeedbackScreen> {
  // ... 其他变量
  bool _isSubmitting = false;

加一个_isSubmitting变量,标记是否正在提交。

dart 复制代码
ElevatedButton(
  onPressed: _isSubmitting ? null : _submitFeedback,
  style: ElevatedButton.styleFrom(
    padding: const EdgeInsets.symmetric(vertical: 16),
  ),
  child: _isSubmitting
      ? const SizedBox(
          height: 20,
          width: 20,
          child: CircularProgressIndicator(strokeWidth: 2),
        )
      : const Text('提交反馈'),
),

_isSubmitting为true时,按钮的onPressed设为null(按钮变灰不可点击),同时显示一个小的加载圈代替文字。

dart 复制代码
Future<void> _submitFeedback() async {
  if (_formKey.currentState!.validate()) {
    setState(() {
      _isSubmitting = true;
    });
    
    try {
      // 模拟网络请求
      await Future.delayed(const Duration(seconds: 2));
      
      // 实际项目中调用API
      // await _feedbackService.submit(
      //   type: _selectedType,
      //   content: _feedbackController.text,
      //   contact: _contactController.text,
      // );
      
      if (mounted) {
        _showSuccessDialog();
      }
    } catch (e) {
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('提交失败:$e')),
        );
      }
    } finally {
      if (mounted) {
        setState(() {
          _isSubmitting = false;
        });
      }
    }
  }
}

这段代码处理了几个重要的细节:

setState设置加载状态:提交前设为true,完成后设为false。

try-catch处理异常:网络请求可能失败,要捕获异常并提示用户。

mounted检查 :异步操作完成后,页面可能已经被销毁了(用户按了返回键)。调用setState或showDialog前要检查mounted,避免报错。

finally确保状态恢复 :无论成功还是失败,都要把_isSubmitting设回false。

添加图片上传功能

有时候用户想上传截图来说明问题。来实现这个功能:

dart 复制代码
class _FeedbackScreenState extends State<FeedbackScreen> {
  // ... 其他变量
  List<File> _selectedImages = [];

用一个列表存储用户选择的图片。

dart 复制代码
Widget _buildImagePicker() {
  return Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      const Text(
        '添加图片(可选)',
        style: TextStyle(
          fontSize: 14,
          color: Colors.grey,
        ),
      ),
      const SizedBox(height: 8),
      Wrap(
        spacing: 8,
        runSpacing: 8,
        children: [
          ..._selectedImages.map((file) => _buildImagePreview(file)),
          if (_selectedImages.length < 4) _buildAddButton(),
        ],
      ),
    ],
  );
}

Wrap组件让图片自动换行。最多允许上传4张图片,达到上限后隐藏添加按钮。

dart 复制代码
Widget _buildImagePreview(File file) {
  return Stack(
    children: [
      ClipRRect(
        borderRadius: BorderRadius.circular(8),
        child: Image.file(
          file,
          width: 80,
          height: 80,
          fit: BoxFit.cover,
        ),
      ),
      Positioned(
        top: -8,
        right: -8,
        child: IconButton(
          icon: const Icon(Icons.cancel, color: Colors.red),
          onPressed: () {
            setState(() {
              _selectedImages.remove(file);
            });
          },
        ),
      ),
    ],
  );
}

每张图片右上角有个删除按钮,用StackPositioned实现定位。点击删除按钮,从列表中移除对应的图片。

dart 复制代码
Widget _buildAddButton() {
  return GestureDetector(
    onTap: _pickImage,
    child: Container(
      width: 80,
      height: 80,
      decoration: BoxDecoration(
        border: Border.all(color: Colors.grey),
        borderRadius: BorderRadius.circular(8),
      ),
      child: const Icon(Icons.add_photo_alternate, color: Colors.grey),
    ),
  );
}

Future<void> _pickImage() async {
  final picker = ImagePicker();
  final pickedFile = await picker.pickImage(
    source: ImageSource.gallery,
    maxWidth: 1024,
    maxHeight: 1024,
    imageQuality: 80,
  );
  
  if (pickedFile != null) {
    setState(() {
      _selectedImages.add(File(pickedFile.path));
    });
  }
}

点击添加按钮,调用ImagePicker打开相册选择图片。maxWidthmaxHeight限制图片尺寸,imageQuality压缩图片质量,减少上传的数据量。

需要在pubspec.yaml添加依赖:

yaml 复制代码
dependencies:
  image_picker: ^1.0.0

实现反馈类型的动态提示

不同的反馈类型,可以给用户不同的提示:

dart 复制代码
String _getHintText() {
  switch (_selectedType) {
    case 'Bug反馈':
      return '请描述Bug的复现步骤、出现频率、影响范围等...';
    case '功能建议':
      return '请描述您希望添加的功能,以及使用场景...';
    case '内容问题':
      return '请说明问题内容的标题、来源,以及具体问题...';
    default:
      return '请详细描述您的问题或建议...';
  }
}

然后在TextFormField里使用:

dart 复制代码
TextFormField(
  controller: _feedbackController,
  maxLines: 8,
  decoration: InputDecoration(
    labelText: '详细描述',
    hintText: _getHintText(),
    border: const OutlineInputBorder(),
  ),
  // ...
),

用户选择"Bug反馈"时,提示会变成"请描述Bug的复现步骤...",引导用户提供更有价值的信息。

保存草稿功能

用户可能写了一半被打断,要能保存草稿:

dart 复制代码
@override
void initState() {
  super.initState();
  _loadDraft();
}

Future<void> _loadDraft() async {
  final prefs = await SharedPreferences.getInstance();
  final draft = prefs.getString('feedback_draft');
  final draftType = prefs.getString('feedback_draft_type');
  final draftContact = prefs.getString('feedback_draft_contact');
  
  if (draft != null && draft.isNotEmpty) {
    setState(() {
      _feedbackController.text = draft;
      if (draftType != null) _selectedType = draftType;
      if (draftContact != null) _contactController.text = draftContact;
    });
    
    // 提示用户已恢复草稿
    WidgetsBinding.instance.addPostFrameCallback((_) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(
          content: Text('已恢复上次的草稿'),
          duration: Duration(seconds: 2),
        ),
      );
    });
  }
}

Future<void> _saveDraft() async {
  final prefs = await SharedPreferences.getInstance();
  await prefs.setString('feedback_draft', _feedbackController.text);
  await prefs.setString('feedback_draft_type', _selectedType);
  await prefs.setString('feedback_draft_contact', _contactController.text);
}

Future<void> _clearDraft() async {
  final prefs = await SharedPreferences.getInstance();
  await prefs.remove('feedback_draft');
  await prefs.remove('feedback_draft_type');
  await prefs.remove('feedback_draft_contact');
}

页面打开时加载草稿,用户输入时保存草稿,提交成功后清除草稿。

dart 复制代码
TextFormField(
  controller: _feedbackController,
  onChanged: (value) {
    _saveDraft();  // 输入时自动保存
  },
  // ...
),

在输入框的onChanged里调用_saveDraft,用户每次输入都会保存。这样即使应用崩溃,草稿也不会丢失。

提交成功后清除草稿:

dart 复制代码
void _showSuccessDialog() {
  _clearDraft();  // 清除草稿
  showDialog(
    // ...
  );
}

退出时的草稿提示

用户按返回键时,如果有未提交的内容,应该提示是否保存:

dart 复制代码
@override
Widget build(BuildContext context) {
  return WillPopScope(
    onWillPop: _onWillPop,
    child: Scaffold(
      // ...
    ),
  );
}

Future<bool> _onWillPop() async {
  if (_feedbackController.text.isEmpty) {
    return true;  // 没有内容,直接返回
  }
  
  final result = await showDialog<bool>(
    context: context,
    builder: (context) => AlertDialog(
      title: const Text('保存草稿?'),
      content: const Text('您有未提交的反馈内容,是否保存为草稿?'),
      actions: [
        TextButton(
          onPressed: () {
            _clearDraft();
            Navigator.pop(context, true);
          },
          child: const Text('不保存'),
        ),
        TextButton(
          onPressed: () {
            _saveDraft();
            Navigator.pop(context, true);
          },
          child: const Text('保存'),
        ),
        TextButton(
          onPressed: () {
            Navigator.pop(context, false);
          },
          child: const Text('继续编辑'),
        ),
      ],
    ),
  );
  
  return result ?? false;
}

WillPopScope可以拦截返回操作。onWillPop返回true允许返回,返回false阻止返回。

对话框提供三个选项:不保存(清除草稿并返回)、保存(保存草稿并返回)、继续编辑(不返回)。

反馈服务的封装

实际项目中,提交反馈需要调用后端API。封装一个服务类:

dart 复制代码
class FeedbackService {
  final String _baseUrl = 'https://api.example.com';
  
  Future<void> submitFeedback({
    required String type,
    required String content,
    String? contact,
    List<File>? images,
  }) async {
    final uri = Uri.parse('$_baseUrl/feedback');
    
    final request = http.MultipartRequest('POST', uri);
    
    // 添加文本字段
    request.fields['type'] = type;
    request.fields['content'] = content;
    if (contact != null && contact.isNotEmpty) {
      request.fields['contact'] = contact;
    }
    
    // 添加设备信息
    request.fields['platform'] = Platform.operatingSystem;
    request.fields['version'] = await _getAppVersion();
    
    // 添加图片
    if (images != null) {
      for (var i = 0; i < images.length; i++) {
        final file = images[i];
        request.files.add(await http.MultipartFile.fromPath(
          'image_$i',
          file.path,
        ));
      }
    }
    
    final response = await request.send();
    
    if (response.statusCode != 200) {
      throw Exception('提交失败:${response.statusCode}');
    }
  }
  
  Future<String> _getAppVersion() async {
    final packageInfo = await PackageInfo.fromPlatform();
    return packageInfo.version;
  }
}

这个服务类做了几件事:

使用MultipartRequest:因为要上传图片,普通的POST请求不行,需要用multipart/form-data格式。

添加设备信息:自动收集平台(iOS/Android)和应用版本,方便后台排查问题。

处理图片上传:把图片文件转成MultipartFile添加到请求中。

收集更多设备信息

为了更好地排查问题,可以收集更多设备信息:

dart 复制代码
Future<Map<String, String>> _collectDeviceInfo() async {
  final deviceInfo = DeviceInfoPlugin();
  final packageInfo = await PackageInfo.fromPlatform();
  
  final Map<String, String> info = {
    'appVersion': packageInfo.version,
    'buildNumber': packageInfo.buildNumber,
  };
  
  if (Platform.isAndroid) {
    final androidInfo = await deviceInfo.androidInfo;
    info['platform'] = 'Android';
    info['osVersion'] = androidInfo.version.release;
    info['device'] = androidInfo.model;
    info['brand'] = androidInfo.brand;
  } else if (Platform.isIOS) {
    final iosInfo = await deviceInfo.iosInfo;
    info['platform'] = 'iOS';
    info['osVersion'] = iosInfo.systemVersion;
    info['device'] = iosInfo.model;
  }
  
  return info;
}

需要添加依赖:

yaml 复制代码
dependencies:
  device_info_plus: ^9.0.0
  package_info_plus: ^4.0.0

这些信息对排查Bug特别有用。比如用户反馈"应用闪退",如果知道是Android 10、华为P40,就能更快定位问题。

常见问题的快捷入口

有些问题用户经常反馈,可以提供快捷入口:

dart 复制代码
Widget _buildQuickFeedback() {
  final quickItems = [
    {'icon': Icons.speed, 'text': '加载太慢'},
    {'icon': Icons.bug_report, 'text': '应用闪退'},
    {'icon': Icons.image_not_supported, 'text': '图片不显示'},
    {'icon': Icons.text_fields, 'text': '内容有误'},
  ];
  
  return Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      const Text(
        '快捷反馈',
        style: TextStyle(
          fontSize: 14,
          fontWeight: FontWeight.bold,
        ),
      ),
      const SizedBox(height: 8),
      Wrap(
        spacing: 8,
        runSpacing: 8,
        children: quickItems.map((item) {
          return ActionChip(
            avatar: Icon(item['icon'] as IconData, size: 18),
            label: Text(item['text'] as String),
            onPressed: () {
              _feedbackController.text = item['text'] as String;
              _selectedType = 'Bug反馈';
              setState(() {});
            },
          );
        }).toList(),
      ),
    ],
  );
}

用户点击"加载太慢",输入框自动填入这个内容,反馈类型自动切换到"Bug反馈"。用户只需要补充一些细节就能提交,降低了反馈的门槛。

反馈历史记录

让用户能看到自己提交过的反馈:

dart 复制代码
class FeedbackHistoryScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('我的反馈'),
      ),
      body: FutureBuilder<List<FeedbackRecord>>(
        future: _feedbackService.getMyFeedbacks(),
        builder: (context, snapshot) {
          if (snapshot.connectionState == ConnectionState.waiting) {
            return const Center(child: CircularProgressIndicator());
          }
          
          if (snapshot.hasError) {
            return Center(child: Text('加载失败:${snapshot.error}'));
          }
          
          final feedbacks = snapshot.data ?? [];
          
          if (feedbacks.isEmpty) {
            return const Center(child: Text('暂无反馈记录'));
          }
          
          return ListView.builder(
            itemCount: feedbacks.length,
            itemBuilder: (context, index) {
              final feedback = feedbacks[index];
              return _buildFeedbackItem(feedback);
            },
          );
        },
      ),
    );
  }
  
  Widget _buildFeedbackItem(FeedbackRecord feedback) {
    return Card(
      margin: const EdgeInsets.all(8),
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
              children: [
                _buildStatusChip(feedback.status),
                const Spacer(),
                Text(
                  _formatDate(feedback.createdAt),
                  style: const TextStyle(color: Colors.grey, fontSize: 12),
                ),
              ],
            ),
            const SizedBox(height: 8),
            Text(
              feedback.content,
              maxLines: 3,
              overflow: TextOverflow.ellipsis,
            ),
            if (feedback.reply != null) ...[
              const Divider(),
              Text(
                '官方回复:${feedback.reply}',
                style: const TextStyle(color: Colors.blue),
              ),
            ],
          ],
        ),
      ),
    );
  }
  
  Widget _buildStatusChip(String status) {
    Color color;
    switch (status) {
      case 'pending':
        color = Colors.orange;
        break;
      case 'processing':
        color = Colors.blue;
        break;
      case 'resolved':
        color = Colors.green;
        break;
      default:
        color = Colors.grey;
    }
    
    return Chip(
      label: Text(
        _getStatusText(status),
        style: const TextStyle(color: Colors.white, fontSize: 12),
      ),
      backgroundColor: color,
    );
  }
}

用户可以看到每条反馈的状态(待处理、处理中、已解决)和官方回复。这样用户知道自己的反馈被重视了,会更愿意继续反馈。

一些细节优化

键盘弹出时自动滚动

当用户点击输入框,键盘弹出时,输入框可能被键盘遮挡。ListView会自动处理这个问题,但如果用的是SingleChildScrollView,需要手动处理:

dart 复制代码
SingleChildScrollView(
  keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
  child: Padding(
    padding: EdgeInsets.only(
      bottom: MediaQuery.of(context).viewInsets.bottom,
    ),
    child: // ...
  ),
)

viewInsets.bottom是键盘的高度,加到底部padding里,内容就不会被遮挡了。

点击空白处收起键盘

dart 复制代码
GestureDetector(
  onTap: () {
    FocusScope.of(context).unfocus();
  },
  child: Scaffold(
    // ...
  ),
)

GestureDetector包裹整个页面,点击空白处调用unfocus()收起键盘。

提交前的二次确认

对于重要操作,可以加个确认:

dart 复制代码
void _submitFeedback() {
  if (_formKey.currentState!.validate()) {
    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text('确认提交'),
        content: Text('反馈类型:$_selectedType\n\n确定要提交吗?'),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: const Text('取消'),
          ),
          TextButton(
            onPressed: () {
              Navigator.pop(context);
              _doSubmit();
            },
            child: const Text('确定'),
          ),
        ],
      ),
    );
  }
}

写在最后

意见反馈看起来是个简单的表单页面,但要做好需要考虑很多细节。

从用户体验角度,要有友好的引导文案、清晰的输入提示、及时的操作反馈。草稿保存、退出确认这些细节,能让用户感受到你的用心。

从功能完整性角度,要支持多种反馈类型、图片上传、设备信息收集。这些信息能帮助开发者更快地定位和解决问题。

从技术实现角度 ,要处理好表单验证、加载状态、异常情况。mounted检查、dispose释放资源这些细节不能忘。

最后提醒一点:收到用户反馈后,一定要认真对待。哪怕只是回复一句"感谢反馈,我们会尽快处理",也能让用户感受到被重视。用户愿意花时间给你反馈,是对你应用的认可,别辜负了这份信任。

欢迎加入开源鸿蒙跨平台社区https://openharmonycrossplatform.csdn.net

在这里你可以找到更多Flutter开发资源,与其他开发者交流经验,共同进步。

相关推荐
程序员老刘·10 小时前
Android Studio Otter 3 发布:日常开发选AS还是Cursor?
flutter·android studio·ai编程·跨平台开发·客户端开发
浩辉_10 小时前
Dart - 内存管理与垃圾回收(GC)深度解析
flutter·dart
一起养小猫12 小时前
Flutter for OpenHarmony 实战:记忆棋游戏完整开发指南
flutter·游戏·harmonyos
Betelgeuse7613 小时前
【Flutter For OpenHarmony】TechHub技术资讯界面开发
flutter·ui·华为·交互·harmonyos
铅笔侠_小龙虾14 小时前
Flutter 安装&配置
flutter
mocoding14 小时前
使用已经完成鸿蒙化适配的Flutter本地持久化存储三方库shared_preferences让你的应用能够保存用户偏好设置、缓存数据等
flutter·华为·harmonyos·鸿蒙
无熵~16 小时前
Flutter入门
flutter
hudawei99617 小时前
要控制动画的widget为什么要with SingleTickerProviderStateMixin
flutter·mixin·with·ticker·动画控制
jian1105818 小时前
flutter dio 依赖,dependencies 和 dev_dependencies的区别
flutter
王码码203518 小时前
Flutter for OpenHarmony 实战之基础组件:第十七篇 滚动进阶 ScrollController 与 Scrollbar
flutter·harmonyos