联系人二维码可以将个人信息编码成二维码,扫描后可以直接添加到通讯录。这在商务场合交换名片时特别方便,不需要手动输入联系方式。这篇文章介绍联系人二维码生成页面的实现,包括 vCard 格式、多字段输入、表单验证等功能。

vCard 格式介绍

联系人二维码使用 vCard 格式编码联系人信息。vCard 是一种电子名片的标准格式,被大多数通讯录应用支持。

基本格式如下:

BEGIN:VCARD
VERSION:3.0
N:姓名
FN:全名
TEL:电话号码
EMAIL:邮箱地址
ORG:公司名称
TITLE:职位
ADR:地址
END:VCARD

各字段说明:

N (Name):姓名,格式为"姓;名"

FN (Full Name):全名,显示名称

TEL:电话号码,可以有多个

EMAIL:邮箱地址

ORG (Organization):公司/组织名称

TITLE:职位/头衔

ADR (Address):地址

ContactQrView 的基础结构

先来看文件的导入和类定义:

import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import '../../data/models/qr_record.dart';
import 'generate_controller.dart';

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

  
  State<ContactQrView> createState() => _ContactQrViewState();
}

导入了必要的包。ContactQrView 使用 StatefulWidget,因为需要管理多个输入框的状态。

状态类的定义

状态类中定义了多个输入控制器:

class _ContactQrViewState extends State<ContactQrView> {
  final _nameController = TextEditingController();
  final _phoneController = TextEditingController();
  final _emailController = TextEditingController();
  final _companyController = TextEditingController();
  final _titleController = TextEditingController();
  final _addressController = TextEditingController();
  final _controller = Get.find<GenerateController>();

  
  void dispose() {
    _nameController.dispose();
    _phoneController.dispose();
    _emailController.dispose();
    _companyController.dispose();
    _titleController.dispose();
    _addressController.dispose();
    super.dispose();
  }

六个 TextEditingController 分别控制姓名、电话、邮箱、公司、职位和地址输入框。dispose 中释放所有控制器,避免内存泄漏。

这里的字段覆盖了名片上最常见的信息,足够满足大多数场景的需求。

vCard 字符串生成方法

生成 vCard 格式字符串的方法:

  String _generateVCard() {
    return '''BEGIN:VCARD
VERSION:3.0
N:${_nameController.text}
FN:${_nameController.text}
TEL:${_phoneController.text}
EMAIL:${_emailController.text}
ORG:${_companyController.text}
TITLE:${_titleController.text}
ADR:${_addressController.text}
END:VCARD''';
  }

使用 Dart 的多行字符串语法(三个引号)生成 vCard 内容。字符串插值将各输入框的内容填入对应字段。

VERSION:3.0 指定 vCard 版本,3.0 是最广泛支持的版本。N 和 FN 都使用姓名,简化处理。

Scaffold 和 AppBar

build 方法返回页面结构:

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('联系人二维码')),
      body: SingleChildScrollView(
        padding: EdgeInsets.all(16.w),
        child: Column(
          children: [

Scaffold 提供基础页面结构,AppBar 标题为"联系人二维码"。body 使用 SingleChildScrollView 包裹,因为有多个输入框,内容较长需要滚动。

输入字段列表

使用封装的方法构建输入字段:

            _buildTextField(_nameController, '姓名', Icons.person, required: true),
            _buildTextField(_phoneController, '电话', Icons.phone, keyboardType: TextInputType.phone),
            _buildTextField(_emailController, '邮箱', Icons.email, keyboardType: TextInputType.emailAddress),
            _buildTextField(_companyController, '公司', Icons.business),
            _buildTextField(_titleController, '职位', Icons.work),
            _buildTextField(_addressController, '地址', Icons.location_on, maxLines: 2),
          ],
        ),
      ),

每个字段调用 _buildTextField 方法,传入控制器、标签、图标和可选参数。姓名字段标记为必填,电话和邮箱使用对应的键盘类型,地址允许多行输入。

这种封装方式让代码更简洁,避免重复的 TextField 配置。

_buildTextField 方法

封装的输入框构建方法:

  Widget _buildTextField(
    TextEditingController controller,
    String label,
    IconData icon, {
    bool required = false,
    TextInputType? keyboardType,
    int maxLines = 1,
  }) {
    return Padding(
      padding: EdgeInsets.only(bottom: 16.h),
      child: TextField(
        controller: controller,
        keyboardType: keyboardType,
        maxLines: maxLines,
        decoration: InputDecoration(
          labelText: required ? '$label *' : label,
          prefixIcon: Icon(icon),
          border: OutlineInputBorder(borderRadius: BorderRadius.circular(8.r)),
        ),
      ),
    );
  }
}

方法接收控制器、标签、图标作为必需参数,required、keyboardType、maxLines 作为可选参数。

labelText 根据 required 参数决定是否显示星号。prefixIcon 显示对应的图标。border 使用圆角边框。

Padding 在每个输入框下方添加 16.h 的间距,让表单看起来更整齐。

底部生成按钮

页面底部是生成按钮:

      bottomNavigationBar: SafeArea(
        child: Padding(
          padding: EdgeInsets.all(16.w),
          child: ElevatedButton(
            onPressed: () => _controller.generateQr(_generateVCard(), QrType.contact),
            style: ElevatedButton.styleFrom(
              minimumSize: Size(double.infinity, 48.h),
              backgroundColor: Theme.of(context).primaryColor,
              foregroundColor: Colors.white,
            ),
            child: const Text('生成二维码'),
          ),
        ),
      ),
    );
  }

点击按钮时调用 _generateVCard() 生成 vCard 字符串,然后调用控制器的 generateQr 方法生成二维码。

输入验证

在生成前验证必填字段:

void _handleGenerate() {
  final name = _nameController.text.trim();
  
  if (name.isEmpty) {
    Get.snackbar('提示', '请输入姓名', snackPosition: SnackPosition.BOTTOM);
    return;
  }
  
  // 至少需要一种联系方式
  final phone = _phoneController.text.trim();
  final email = _emailController.text.trim();
  
  if (phone.isEmpty && email.isEmpty) {
    Get.snackbar('提示', '请至少输入电话或邮箱', snackPosition: SnackPosition.BOTTOM);
    return;
  }
  
  // 验证邮箱格式
  if (email.isNotEmpty && !GetUtils.isEmail(email)) {
    Get.snackbar('提示', '请输入有效的邮箱地址', snackPosition: SnackPosition.BOTTOM);
    return;
  }
  
  _controller.generateQr(_generateVCard(), QrType.contact);
}

验证逻辑包括:姓名必填、至少有一种联系方式、邮箱格式正确。GetUtils.isEmail 是 GetX 提供的邮箱验证方法。

多电话号码支持

支持添加多个电话号码:

class _ContactQrViewState extends State<ContactQrView> {
  final List<TextEditingController> _phoneControllers = [TextEditingController()];
  
  void _addPhone() {
    setState(() {
      _phoneControllers.add(TextEditingController());
    });
  }
  
  void _removePhone(int index) {
    if (_phoneControllers.length > 1) {
      setState(() {
        _phoneControllers[index].dispose();
        _phoneControllers.removeAt(index);
      });
    }
  }

使用列表存储多个电话控制器。_addPhone 添加新的输入框,_removePhone 移除指定的输入框(至少保留一个)。

多电话输入 UI

Widget _buildPhoneFields() {
  return Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      Row(
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        children: [
          Text('电话', style: TextStyle(fontSize: 14.sp, fontWeight: FontWeight.w500)),
          TextButton.icon(
            icon: const Icon(Icons.add, size: 18),
            label: const Text('添加'),
            onPressed: _addPhone,
          ),
        ],
      ),
      SizedBox(height: 8.h),
      ...List.generate(_phoneControllers.length, (index) => Padding(
        padding: EdgeInsets.only(bottom: 8.h),
        child: Row(
          children: [
            Expanded(
              child: TextField(
                controller: _phoneControllers[index],
                keyboardType: TextInputType.phone,
                decoration: InputDecoration(
                  hintText: '输入电话号码',
                  prefixIcon: const Icon(Icons.phone),
                  border: OutlineInputBorder(borderRadius: BorderRadius.circular(8.r)),
                ),
              ),
            ),
            if (_phoneControllers.length > 1)
              IconButton(
                icon: const Icon(Icons.remove_circle_outline, color: Colors.red),
                onPressed: () => _removePhone(index),
              ),
          ],
        ),
      )),
    ],
  );
}

标题行包含"添加"按钮。每个电话输入框右侧有删除按钮(只有多于一个时显示)。

更新 vCard 生成方法

支持多电话的 vCard 生成:

String _generateVCard() {
  final buffer = StringBuffer();
  buffer.writeln('BEGIN:VCARD');
  buffer.writeln('VERSION:3.0');
  buffer.writeln('N:${_nameController.text}');
  buffer.writeln('FN:${_nameController.text}');
  
  for (final controller in _phoneControllers) {
    if (controller.text.isNotEmpty) {
      buffer.writeln('TEL:${controller.text}');
    }
  }
  
  if (_emailController.text.isNotEmpty) {
    buffer.writeln('EMAIL:${_emailController.text}');
  }
  if (_companyController.text.isNotEmpty) {
    buffer.writeln('ORG:${_companyController.text}');
  }
  if (_titleController.text.isNotEmpty) {
    buffer.writeln('TITLE:${_titleController.text}');
  }
  if (_addressController.text.isNotEmpty) {
    buffer.writeln('ADR:${_addressController.text}');
  }
  
  buffer.writeln('END:VCARD');
  return buffer.toString();
}

使用 StringBuffer 拼接字符串,遍历所有电话控制器添加 TEL 字段。只有非空字段才会添加到 vCard 中。

从通讯录导入

提供从通讯录导入联系人的功能:

import 'package:contacts_service/contacts_service.dart';

Future<void> _importFromContacts() async {
  final contact = await ContactsService.openDeviceContactPicker();
  
  if (contact != null) {
    _nameController.text = contact.displayName ?? '';
    
    if (contact.phones?.isNotEmpty ?? false) {
      _phoneControllers.first.text = contact.phones!.first.value ?? '';
    }
    
    if (contact.emails?.isNotEmpty ?? false) {
      _emailController.text = contact.emails!.first.value ?? '';
    }
    
    if (contact.company != null) {
      _companyController.text = contact.company!;
    }
    
    if (contact.jobTitle != null) {
      _titleController.text = contact.jobTitle!;
    }
    
    Get.snackbar('成功', '已导入联系人信息', snackPosition: SnackPosition.BOTTOM);
  }
}

使用 contacts_service 插件打开系统通讯录选择器。选择联系人后,将信息填充到各输入框。

导入按钮

在 AppBar 添加导入按钮:

AppBar(
  title: const Text('联系人二维码'),
  actions: [
    IconButton(
      icon: const Icon(Icons.contacts),
      onPressed: _importFromContacts,
      tooltip: '从通讯录导入',
    ),
  ],
),

点击通讯录图标可以快速导入已有联系人,不需要手动输入。

名片预览

显示名片样式的预览:

Widget _buildCardPreview() {
  return Card(
    margin: EdgeInsets.only(bottom: 16.h),
    child: Padding(
      padding: EdgeInsets.all(16.w),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Row(
            children: [
              CircleAvatar(
                radius: 30.r,
                child: Text(
                  _nameController.text.isNotEmpty 
                      ? _nameController.text[0].toUpperCase() 
                      : '?',
                  style: TextStyle(fontSize: 24.sp),
                ),
              ),
              SizedBox(width: 16.w),
              Expanded(
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text(
                      _nameController.text.isEmpty ? '姓名' : _nameController.text,
                      style: TextStyle(fontSize: 18.sp, fontWeight: FontWeight.bold),
                    ),
                    if (_titleController.text.isNotEmpty || _companyController.text.isNotEmpty)
                      Text(
                        '${_titleController.text} ${_companyController.text}'.trim(),
                        style: TextStyle(fontSize: 14.sp, color: Colors.grey),
                      ),
                  ],
                ),
              ),
            ],
          ),
          if (_phoneControllers.first.text.isNotEmpty) ...[
            SizedBox(height: 12.h),
            Row(
              children: [
                Icon(Icons.phone, size: 16.sp, color: Colors.grey),
                SizedBox(width: 8.w),
                Text(_phoneControllers.first.text, style: TextStyle(fontSize: 14.sp)),
              ],
            ),
          ],
          if (_emailController.text.isNotEmpty) ...[
            SizedBox(height: 8.h),
            Row(
              children: [
                Icon(Icons.email, size: 16.sp, color: Colors.grey),
                SizedBox(width: 8.w),
                Text(_emailController.text, style: TextStyle(fontSize: 14.sp)),
              ],
            ),
          ],
        ],
      ),
    ),
  );
}

名片预览显示头像(使用姓名首字母)、姓名、职位公司、电话和邮箱。实时更新,让用户看到最终效果。

保存为模板

保存常用的联系人信息为模板:

void _saveAsTemplate() async {
  final name = _nameController.text.trim();
  if (name.isEmpty) {
    Get.snackbar('提示', '请先输入姓名', snackPosition: SnackPosition.BOTTOM);
    return;
  }
  
  final prefs = await SharedPreferences.getInstance();
  final templates = prefs.getStringList('contact_templates') ?? [];
  
  final template = jsonEncode({
    'name': _nameController.text,
    'phone': _phoneControllers.first.text,
    'email': _emailController.text,
    'company': _companyController.text,
    'title': _titleController.text,
    'address': _addressController.text,
  });
  
  templates.add(template);
  await prefs.setStringList('contact_templates', templates);
  
  Get.snackbar('成功', '已保存为模板', snackPosition: SnackPosition.BOTTOM);
}

将联系人信息序列化为 JSON 保存。下次可以快速加载模板生成二维码。

清空表单

提供清空所有输入的功能:

void _clearForm() {
  _nameController.clear();
  for (final controller in _phoneControllers) {
    controller.clear();
  }
  _emailController.clear();
  _companyController.clear();
  _titleController.clear();
  _addressController.clear();
  
  setState(() {});
}

清空所有输入框,方便用户重新输入。

表单自动保存

自动保存表单内容,防止意外丢失:


void initState() {
  super.initState();
  _loadDraft();
  
  // 监听输入变化,自动保存草稿
  _nameController.addListener(_saveDraft);
  _phoneControllers.first.addListener(_saveDraft);
  _emailController.addListener(_saveDraft);
}

Future<void> _saveDraft() async {
  final prefs = await SharedPreferences.getInstance();
  await prefs.setString('contact_draft', jsonEncode({
    'name': _nameController.text,
    'phone': _phoneControllers.first.text,
    'email': _emailController.text,
    'company': _companyController.text,
    'title': _titleController.text,
    'address': _addressController.text,
  }));
}

Future<void> _loadDraft() async {
  final prefs = await SharedPreferences.getInstance();
  final draft = prefs.getString('contact_draft');
  
  if (draft != null) {
    final data = jsonDecode(draft);
    _nameController.text = data['name'] ?? '';
    _phoneControllers.first.text = data['phone'] ?? '';
    _emailController.text = data['email'] ?? '';
    _companyController.text = data['company'] ?? '';
    _titleController.text = data['title'] ?? '';
    _addressController.text = data['address'] ?? '';
  }
}

输入变化时自动保存草稿,页面打开时恢复草稿内容。

小结

联系人二维码生成页面需要处理多个输入字段,使用 vCard 格式编码联系人信息。页面使用封装的输入框构建方法,让代码更简洁。

支持多电话号码、从通讯录导入、名片预览、模板保存等功能,提升了用户体验。表单验证确保生成的二维码包含有效的联系人信息。

联系人二维码在商务场合非常实用,一个好用的生成页面可以让交换名片变得更加便捷。


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

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐