Flutter 与联系人管理桥接技术详解
引言:联系人管理在现代应用中的重要性
联系人管理是许多移动应用的核心功能之一。无论是社交应用、通讯工具、客户关系管理系统,还是简单的拨号应用,都需要访问和管理用户的联系人信息。Flutter 作为跨平台框架,提供了与原生联系人 API 桥接的能力,使开发者能够在 Android 和 iOS 平台上实现高效的联系人管理功能。
本文将通过一个实际案例——开发一款名为"ContactHub"的智能联系人管理应用——来详细介绍 Flutter 中实现联系人管理的技术细节和最佳实践。
联系人管理技术概述
联系人数据结构
- 基本信息:姓名、电话号码、邮箱地址
- 详细信息:公司、职位、地址、生日
- 社交信息:社交媒体账号、即时通讯 ID
- 自定义字段:用户自定义的额外信息
联系人操作类型
- 查询操作:读取联系人列表、搜索联系人
- 创建操作:添加新联系人
- 更新操作:修改现有联系人信息
- 删除操作:删除联系人
- 同步操作:与云端服务同步联系人
项目背景:ContactHub 智能联系人管理应用
我们的项目是开发一款名为 ContactHub 的智能联系人管理应用,支持以下功能:
- 联系人列表查看和搜索
- 联系人详情编辑
- 联系人分组管理
- 联系人备份和恢复
- 智能联系人推荐
- 通话记录集成
技术架构设计
整体架构
┌─────────────────────────────────────────────────────────────┐
│ Flutter应用层 │
├─────────────────────────────────────────────────────────────┤
│ 联系人UI │ 分组UI │ 搜索UI │ 设置页面 │
├─────────────────────────────────────────────────────────────┤
│ 联系人服务管理层 │
├─────────────────────────────────────────────────────────────┤
│ 平台通道桥接层 │
├─────────────────────────────────────────────────────────────┤
│ Android ContactsContract │ iOS Contacts Framework │
└─────────────────────────────────────────────────────────────┘核心组件
- ContactService:联系人数据管理
- ContactGroupService:联系人分组管理
- ContactSearchService:联系人搜索功能
- ContactBackupService:联系人备份恢复
- PlatformChannel:平台通道通信
实现步骤详解
第一步:添加依赖和配置
首先,我们需要添加必要的依赖包:
yaml
dependencies:
flutter:
sdk: flutter
permission_handler: ^10.2.0
sqflite: ^2.3.0
path_provider: ^2.1.0
shared_preferences: ^2.2.0
flutter_contacts: ^1.1.7
image_picker: ^1.0.4
url_launcher: ^6.1.12
call_log: ^1.0.3Android 平台需要配置权限:
xml
<!-- android/app/src/main/AndroidManifest.xml -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 联系人权限 -->
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.WRITE_CONTACTS" />
<!-- 通话记录权限 -->
<uses-permission android:name="android.permission.READ_CALL_LOG" />
<!-- 相机权限(用于头像) -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
</manifest>iOS 平台需要在 Info.plist 中添加权限说明:
xml
<!-- ios/Runner/Info.plist -->
<key>NSContactsUsageDescription</key>
<string>此应用需要访问您的联系人来提供联系人管理服务</string>
<key>NSCameraUsageDescription</key>
<string>此应用需要访问相机来拍摄联系人头像</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>此应用需要访问相册来选择联系人头像</string>第二步:创建联系人数据模型
dart
// lib/models/contact.dart
import 'package:flutter_contacts/flutter_contacts.dart';
class Contact {
final String id;
final String firstName;
final String lastName;
final String displayName;
final List<PhoneNumber> phones;
final List<Email> emails;
final PostalAddress? address;
final Company? company;
final DateTime? birthday;
final String? notes;
final String? avatar;
final List<String> groups;
final DateTime createdAt;
final DateTime updatedAt;
Contact({
required this.id,
required this.firstName,
required this.lastName,
required this.displayName,
required this.phones,
required this.emails,
this.address,
this.company,
this.birthday,
this.notes,
this.avatar,
this.groups = const [],
DateTime? createdAt,
DateTime? updatedAt,
}) : createdAt = createdAt ?? DateTime.now(),
updatedAt = updatedAt ?? DateTime.now();
factory Contact.fromFlutterContact(FlutterContact flutterContact) {
return Contact(
id: flutterContact.id,
firstName: flutterContact.name.first,
lastName: flutterContact.name.last,
displayName: flutterContact.displayName,
phones: flutterContact.phones,
emails: flutterContact.emails,
address: flutterContact.addresses.isNotEmpty ? flutterContact.addresses.first : null,
company: flutterContact.organizations.isNotEmpty ? Company.fromOrganization(flutterContact.organizations.first) : null,
birthday: flutterContact.events.isNotEmpty && flutterContact.events.first.type == EventType.birthday
? DateTime(flutterContact.events.first.year ?? 2000, flutterContact.events.first.month, flutterContact.events.first.day)
: null,
notes: flutterContact.notes.isNotEmpty ? flutterContact.notes.first : null,
avatar: flutterContact.photo,
groups: flutterContact.groups.map((group) => group.name).toList(),
);
}
Contact copyWith({
String? id,
String? firstName,
String? lastName,
String? displayName,
List<PhoneNumber>? phones,
List<Email>? emails,
PostalAddress? address,
Company? company,
DateTime? birthday,
String? notes,
String? avatar,
List<String>? groups,
DateTime? createdAt,
DateTime? updatedAt,
}) {
return Contact(
id: id ?? this.id,
firstName: firstName ?? this.firstName,
lastName: lastName ?? this.lastName,
displayName: displayName ?? this.displayName,
phones: phones ?? this.phones,
emails: emails ?? this.emails,
address: address ?? this.address,
company: company ?? this.company,
birthday: birthday ?? this.birthday,
notes: notes ?? this.notes,
avatar: avatar ?? this.avatar,
groups: groups ?? this.groups,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? DateTime.now(),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'firstName': firstName,
'lastName': lastName,
'displayName': displayName,
'phones': phones.map((phone) => {
'number': phone.number,
'label': phone.label,
}).toList(),
'emails': emails.map((email) => {
'address': email.address,
'label': email.label,
}).toList(),
'address': address != null ? {
'street': address!.street,
'city': address!.city,
'state': address!.state,
'postalCode': address!.postalCode,
'country': address!.country,
} : null,
'company': company?.toJson(),
'birthday': birthday?.toIso8601String(),
'notes': notes,
'avatar': avatar,
'groups': groups,
'createdAt': createdAt.toIso8601String(),
'updatedAt': updatedAt.toIso8601String(),
};
}
factory Contact.fromJson(Map<String, dynamic> json) {
return Contact(
id: json['id'],
firstName: json['firstName'],
lastName: json['lastName'],
displayName: json['displayName'],
phones: (json['phones'] as List<dynamic>).map((phone) => PhoneNumber(
number: phone['number'],
label: phone['label'],
)).toList(),
emails: (json['emails'] as List<dynamic>).map((email) => Email(
address: email['address'],
label: email['label'],
)).toList(),
address: json['address'] != null ? PostalAddress(
street: json['address']['street'],
city: json['address']['city'],
state: json['address']['state'],
postalCode: json['address']['postalCode'],
country: json['address']['country'],
) : null,
company: json['company'] != null ? Company.fromJson(json['company']) : null,
birthday: json['birthday'] != null ? DateTime.parse(json['birthday']) : null,
notes: json['notes'],
avatar: json['avatar'],
groups: List<String>.from(json['groups'] ?? []),
createdAt: DateTime.parse(json['createdAt']),
updatedAt: DateTime.parse(json['updatedAt']),
);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is Contact && other.id == id;
}
@override
int get hashCode => id.hashCode;
@override
String toString() {
return 'Contact(id: $id, displayName: $displayName, phones: ${phones.length}, emails: ${emails.length})';
}
}
class Company {
final String name;
final String? title;
final String? department;
Company({
required this.name,
this.title,
this.department,
});
factory Company.fromOrganization(Organization organization) {
return Company(
name: organization.company,
title: organization.title,
department: organization.department,
);
}
Map<String, dynamic> toJson() {
return {
'name': name,
'title': title,
'department': department,
};
}
factory Company.fromJson(Map<String, dynamic> json) {
return Company(
name: json['name'],
title: json['title'],
department: json['department'],
);
}
}
class ContactGroup {
final String id;
final String name;
final String? description;
final String? color;
final DateTime createdAt;
final DateTime updatedAt;
ContactGroup({
required this.id,
required this.name,
this.description,
this.color,
DateTime? createdAt,
DateTime? updatedAt,
}) : createdAt = createdAt ?? DateTime.now(),
updatedAt = updatedAt ?? DateTime.now();
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'description': description,
'color': color,
'createdAt': createdAt.toIso8601String(),
'updatedAt': updatedAt.toIso8601String(),
};
}
factory ContactGroup.fromJson(Map<String, dynamic> json) {
return ContactGroup(
id: json['id'],
name: json['name'],
description: json['description'],
color: json['color'],
createdAt: DateTime.parse(json['createdAt']),
updatedAt: DateTime.parse(json['updatedAt']),
);
}
}
class CallRecord {
final String id;
final String contactId;
final String? contactName;
final String phoneNumber;
final CallType callType;
final DateTime timestamp;
final Duration duration;
CallRecord({
required this.id,
required this.contactId,
this.contactName,
required this.phoneNumber,
required this.callType,
required this.timestamp,
required this.duration,
});
Map<String, dynamic> toJson() {
return {
'id': id,
'contactId': contactId,
'contactName': contactName,
'phoneNumber': phoneNumber,
'callType': callType.index,
'timestamp': timestamp.toIso8601String(),
'duration': duration.inSeconds,
};
}
factory CallRecord.fromJson(Map<String, dynamic> json) {
return CallRecord(
id: json['id'],
contactId: json['contactId'],
contactName: json['contactName'],
phoneNumber: json['phoneNumber'],
callType: CallType.values[json['callType']],
timestamp: DateTime.parse(json['timestamp']),
duration: Duration(seconds: json['duration']),
);
}
}
enum CallType {
incoming,
outgoing,
missed,
}第三步:创建联系人服务管理器
dart
// lib/services/contact_service.dart
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_contacts/flutter_contacts.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:call_log/call_log.dart';
import '../models/contact.dart';
class ContactService {
static final ContactService _instance = ContactService._internal();
factory ContactService() => _instance;
ContactService._internal();
final StreamController<List<Contact>> _contactsStreamController = StreamController<List<Contact>>.broadcast();
final StreamController<Contact?> _selectedContactStreamController = StreamController<Contact?>.broadcast();
List<Contact> _contacts = [];
Contact? _selectedContact;
bool _isLoading = false;
String? _error;
// 联系人列表流
Stream<List<Contact>> get contactsStream => _contactsStreamController.stream;
// 选中联系人流
Stream<Contact?> get selectedContactStream => _selectedContactStreamController.stream;
// 当前联系人列表
List<Contact> get contacts => List.unmodifiable(_contacts);
// 当前选中的联系人
Contact? get selectedContact => _selectedContact;
// 加载状态
bool get isLoading => _isLoading;
// 错误信息
String? get error => _error;
// 初始化联系人服务
Future<void> initialize() async {
try {
// 检查权限
final hasPermission = await _requestPermissions();
if (!hasPermission) {
throw ContactException('联系人权限被拒绝');
}
// 加载联系人
await _loadContacts();
} catch (e) {
_error = e.toString();
throw ContactException('初始化联系人服务失败: $e');
}
}
// 请求权限
Future<bool> _requestPermissions() async {
try {
// 请求联系人权限
final contactPermission = await Permission.contacts.request();
if (!contactPermission.isGranted) {
return false;
}
// Android需要额外请求通话记录权限
if (Platform.isAndroid) {
final callLogPermission = await Permission.phone.request();
if (!callLogPermission.isGranted) {
// 通话记录权限是可选的,不影响主要功能
}
}
return true;
} catch (e) {
throw ContactException('请求权限失败: $e');
}
}
// 加载联系人
Future<void> _loadContacts() async {
_isLoading = true;
_error = null;
_notifyListeners();
try {
// 检查权限
final hasPermission = await FlutterContacts.requestPermission();
if (!hasPermission) {
throw ContactException('没有联系人权限');
}
// 获取联系人列表
final flutterContacts = await FlutterContacts.getContacts(
withProperties: true,
withPhoto: true,
withGroups: true,
);
// 转换为自定义Contact对象
_contacts = flutterContacts.map((fc) => Contact.fromFlutterContact(fc)).toList();
// 按显示名称排序
_contacts.sort((a, b) => a.displayName.compareTo(b.displayName));
_notifyListeners();
} catch (e) {
_error = e.toString();
_notifyListeners();
throw ContactException('加载联系人失败: $e');
} finally {
_isLoading = false;
_notifyListeners();
}
}
// 刷新联系人
Future<void> refreshContacts() async {
await _loadContacts();
}
// 搜索联系人
List<Contact> searchContacts(String query) {
if (query.isEmpty) return _contacts;
final lowerQuery = query.toLowerCase();
return _contacts.where((contact) {
return contact.displayName.toLowerCase().contains(lowerQuery) ||
contact.firstName.toLowerCase().contains(lowerQuery) ||
contact.lastName.toLowerCase().contains(lowerQuery) ||
contact.phones.any((phone) => phone.number.contains(query)) ||
contact.emails.any((email) => email.address.toLowerCase().contains(lowerQuery)) ||
(contact.company?.name.toLowerCase().contains(lowerQuery) ?? false);
}).toList();
}
// 按首字母分组联系人
Map<String, List<Contact>> groupContactsByFirstLetter() {
final Map<String, List<Contact>> groupedContacts = {};
for (final contact in _contacts) {
String firstLetter = '#';
if (contact.displayName.isNotEmpty) {
firstLetter = contact.displayName[0].toUpperCase();
}
if (!groupedContacts.containsKey(firstLetter)) {
groupedContacts[firstLetter] = [];
}
groupedContacts[firstLetter]!.add(contact);
}
return groupedContacts;
}
// 获取联系人详情
Future<Contact?> getContactById(String id) async {
try {
final flutterContact = await FlutterContacts.getContact(id);
if (flutterContact == null) return null;
return Contact.fromFlutterContact(flutterContact);
} catch (e) {
throw ContactException('获取联系人详情失败: $e');
}
}
// 选择联系人
void selectContact(Contact? contact) {
_selectedContact = contact;
_selectedContactStreamController.add(contact);
}
// 创建联系人
Future<Contact> createContact(Contact contact) async {
try {
// 创建FlutterContact对象
final flutterContact = _convertToFlutterContact(contact);
// 插入联系人
final insertedContact = await FlutterContacts.insertContact(flutterContact);
// 转换回自定义Contact对象
final newContact = Contact.fromFlutterContact(insertedContact);
// 添加到本地列表
_contacts.add(newContact);
_contacts.sort((a, b) => a.displayName.compareTo(b.displayName));
_notifyListeners();
return newContact;
} catch (e) {
throw ContactException('创建联系人失败: $e');
}
}
// 更新联系人
Future<Contact> updateContact(Contact contact) async {
try {
// 获取原始联系人
final flutterContact = await FlutterContacts.getContact(contact.id);
if (flutterContact == null) {
throw ContactException('联系人不存在');
}
// 更新联系人信息
final updatedFlutterContact = _updateFlutterContact(flutterContact, contact);
// 保存更新
final savedContact = await FlutterContacts.updateContact(updatedFlutterContact);
// 转换回自定义Contact对象
final updatedContact = Contact.fromFlutterContact(savedContact);
// 更新本地列表
final index = _contacts.indexWhere((c) => c.id == contact.id);
if (index != -1) {
_contacts[index] = updatedContact;
_contacts.sort((a, b) => a.displayName.compareTo(b.displayName));
}
// 更新选中的联系人
if (_selectedContact?.id == contact.id) {
_selectedContact = updatedContact;
_selectedContactStreamController.add(updatedContact);
}
_notifyListeners();
return updatedContact;
} catch (e) {
throw ContactException('更新联系人失败: $e');
}
}
// 删除联系人
Future<void> deleteContact(String contactId) async {
try {
// 删除联系人
await FlutterContacts.deleteContact(contactId);
// 从本地列表移除
_contacts.removeWhere((c) => c.id == contactId);
// 清除选中的联系人
if (_selectedContact?.id == contactId) {
_selectedContact = null;
_selectedContactStreamController.add(null);
}
_notifyListeners();
} catch (e) {
throw ContactException('删除联系人失败: $e');
}
}
// 获取通话记录
Future<List<CallRecord>> getCallRecords({String? contactId}) async {
try {
if (Platform.isAndroid) {
final hasPermission = await Permission.phone.request();
if (!hasPermission.isGranted) {
return [];
}
final Iterable<CallLogEntry> entries = await CallLog.get();
final callRecords = entries.map((entry) => CallRecord(
id: entry.timestamp.toString(),
contactId: '', // 需要通过电话号码匹配联系人ID
contactName: entry.name,
phoneNumber: entry.number ?? '',
callType: _convertCallType(entry.callType ?? CallType.unknown),
timestamp: DateTime.fromMillisecondsSinceEpoch(entry.timestamp ?? 0),
duration: Duration(seconds: entry.duration ?? 0),
)).toList();
if (contactId != null) {
final contact = _contacts.firstWhere((c) => c.id == contactId);
final contactNumbers = contact.phones.map((p) => p.number).toSet();
return callRecords.where((record) => contactNumbers.contains(record.phoneNumber)).toList();
}
return callRecords;
} else {
// iOS不支持通话记录访问
return [];
}
} catch (e) {
throw ContactException('获取通话记录失败: $e');
}
}
// 获取常用联系人
List<Contact> getFrequentContacts({int limit = 10}) {
// 这里可以根据通话记录、短信记录等计算常用联系人
// 简化实现,返回前几个联系人
return _contacts.take(limit).toList();
}
// 获取最近联系人
List<Contact> getRecentContacts({int limit = 10}) {
// 这里可以根据最近通话、最近短信等获取最近联系人
// 简化实现,返回前几个联系人
return _contacts.take(limit).toList();
}
// 转换为FlutterContact对象
FlutterContact _convertToFlutterContact(Contact contact) {
return FlutterContact(
id: contact.id,
name: Name(
first: contact.firstName,
last: contact.lastName,
),
phones: contact.phones,
emails: contact.emails,
addresses: contact.address != null ? [contact.address!] : [],
organizations: contact.company != null ? [
Organization(
company: contact.company!.name,
title: contact.company!.title,
department: contact.company!.department,
)
] : [],
events: contact.birthday != null ? [
Event(
year: contact.birthday!.year,
month: contact.birthday!.month,
day: contact.birthday!.day,
type: EventType.birthday,
)
] : [],
notes: contact.notes != null ? [contact.notes!] : [],
photo: contact.avatar,
);
}
// 更新FlutterContact对象
FlutterContact _updateFlutterContact(FlutterContact flutterContact, Contact contact) {
flutterContact.name.first = contact.firstName;
flutterContact.name.last = contact.lastName;
flutterContact.phones = contact.phones;
flutterContact.emails = contact.emails;
flutterContact.addresses = contact.address != null ? [contact.address!] : [];
flutterContact.organizations = contact.company != null ? [
Organization(
company: contact.company!.name,
title: contact.company!.title,
department: contact.company!.department,
)
] : [];
flutterContact.events = contact.birthday != null ? [
Event(
year: contact.birthday!.year,
month: contact.birthday!.month,
day: contact.birthday!.day,
type: EventType.birthday,
)
] : [];
flutterContact.notes = contact.notes != null ? [contact.notes!] : [];
flutterContact.photo = contact.avatar;
return flutterContact;
}
// 转换通话类型
CallType _convertCallType(CallLogEntryType callLogType) {
switch (callLogType) {
case CallLogEntryType.incoming:
return CallType.incoming;
case CallLogEntryType.outgoing:
return CallType.outgoing;
case CallLogEntryType.missed:
return CallType.missed;
default:
return CallType.incoming;
}
}
// 通知监听器
void _notifyListeners() {
_contactsStreamController.add(_contacts);
}
// 释放资源
void dispose() {
_contactsStreamController.close();
_selectedContactStreamController.close();
}
}
// 联系人异常
class ContactException implements Exception {
final String message;
ContactException(this.message);
@override
String toString() => message;
}第四步:实现联系人分组管理
dart
// lib/services/contact_group_service.dart
import 'dart:async';
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
import '../models/contact.dart';
class ContactGroupService {
static final ContactGroupService _instance = ContactGroupService._internal();
factory ContactGroupService() => _instance;
ContactGroupService._internal();
Database? _database;
final StreamController<List<ContactGroup>> _groupsStreamController = StreamController<List<ContactGroup>>.broadcast();
List<ContactGroup> _groups = [];
// 分组列表流
Stream<List<ContactGroup>> get groupsStream => _groupsStreamController.stream;
// 当前分组列表
List<ContactGroup> get groups => List.unmodifiable(_groups);
// 初始化数据库
Future<Database> get database async {
if (_database != null) return _database!;
_database = await _initDatabase();
return _database!;
}
// 初始化数据库
Future<Database> _initDatabase() async {
final databasesPath = await getDatabasesPath();
final path = join(databasesPath, 'contact_groups.db');
return await openDatabase(
path,
version: 1,
onCreate: _onCreate,
);
}
// 创建数据库表
Future<void> _onCreate(Database db, int version) async {
// 分组表
await db.execute('''
CREATE TABLE groups (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
color TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
''');
// 联系人分组关联表
await db.execute('''
CREATE TABLE contact_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
contact_id TEXT NOT NULL,
group_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
FOREIGN KEY (group_id) REFERENCES groups (id) ON DELETE CASCADE
)
''');
// 创建索引
await db.execute('CREATE INDEX idx_contact_groups_contact_id ON contact_groups(contact_id)');
await db.execute('CREATE INDEX idx_contact_groups_group_id ON contact_groups(group_id)');
// 插入默认分组
await _insertDefaultGroups(db);
}
// 插入默认分组
Future<void> _insertDefaultGroups(Database db) async {
final now = DateTime.now().millisecondsSinceEpoch;
final defaultGroups = [
ContactGroup(
id: 'family',
name: '家人',
description: '家庭成员',
color: '#FF5722',
createdAt: DateTime.fromMillisecondsSinceEpoch(now),
updatedAt: DateTime.fromMillisecondsSinceEpoch(now),
),
ContactGroup(
id: 'friends',
name: '朋友',
description: '朋友',
color: '#2196F3',
createdAt: DateTime.fromMillisecondsSinceEpoch(now),
updatedAt: DateTime.fromMillisecondsSinceEpoch(now),
),
ContactGroup(
id: 'work',
name: '工作',
description: '工作同事',
color: '#4CAF50',
createdAt: DateTime.fromMillisecondsSinceEpoch(now),
updatedAt: DateTime.fromMillisecondsSinceEpoch(now),
),
];
for (final group in defaultGroups) {
await db.insert('groups', group.toJson());
}
}
// 加载分组
Future<void> loadGroups() async {
try {
final db = await database;
final List<Map<String, dynamic>> maps = await db.query('groups');
_groups = maps.map((map) => ContactGroup.fromJson(map)).toList();
_groupsStreamController.add(_groups);
} catch (e) {
throw ContactGroupException('加载分组失败: $e');
}
}
// 创建分组
Future<ContactGroup> createGroup(ContactGroup group) async {
try {
final db = await database;
// 检查分组名称是否已存在
final existingGroup = await db.query(
'groups',
where: 'name = ?',
whereArgs: [group.name],
);
if (existingGroup.isNotEmpty) {
throw ContactGroupException('分组名称已存在');
}
// 插入新分组
final id = await db.insert('groups', group.toJson());
// 获取插入的分组
final maps = await db.query('groups', where: 'rowid = ?', whereArgs: [id]);
final newGroup = ContactGroup.fromJson(maps.first);
_groups.add(newGroup);
_groupsStreamController.add(_groups);
return newGroup;
} catch (e) {
throw ContactGroupException('创建分组失败: $e');
}
}
// 更新分组
Future<ContactGroup> updateGroup(ContactGroup group) async {
try {
final db = await database;
// 检查分组是否存在
final existingGroup = await db.query('groups', where: 'id = ?', whereArgs: [group.id]);
if (existingGroup.isEmpty) {
throw ContactGroupException('分组不存在');
}
// 检查分组名称是否与其他分组重复
final duplicateGroup = await db.query(
'groups',
where: 'name = ? AND id != ?',
whereArgs: [group.name, group.id],
);
if (duplicateGroup.isNotEmpty) {
throw ContactGroupException('分组名称已存在');
}
// 更新分组
await db.update(
'groups',
group.toJson(),
where: 'id = ?',
whereArgs: [group.id],
);
// 更新本地列表
final index = _groups.indexWhere((g) => g.id == group.id);
if (index != -1) {
_groups[index] = group;
_groupsStreamController.add(_groups);
}
return group;
} catch (e) {
throw ContactGroupException('更新分组失败: $e');
}
}
// 删除分组
Future<void> deleteGroup(String groupId) async {
try {
final db = await database;
// 删除分组(级联删除关联记录)
await db.delete('groups', where: 'id = ?', whereArgs: [groupId]);
// 从本地列表移除
_groups.removeWhere((g) => g.id == groupId);
_groupsStreamController.add(_groups);
} catch (e) {
throw ContactGroupException('删除分组失败: $e');
}
}
// 添加联系人到分组
Future<void> addContactToGroup(String contactId, String groupId) async {
try {
final db = await database;
// 检查是否已存在
final existing = await db.query(
'contact_groups',
where: 'contact_id = ? AND group_id = ?',
whereArgs: [contactId, groupId],
);
if (existing.isNotEmpty) {
return; // 已存在,不需要重复添加
}
// 添加关联
await db.insert('contact_groups', {
'contact_id': contactId,
'group_id': groupId,
'created_at': DateTime.now().millisecondsSinceEpoch,
});
} catch (e) {
throw ContactGroupException('添加联系人到分组失败: $e');
}
}
// 从分组移除联系人
Future<void> removeContactFromGroup(String contactId, String groupId) async {
try {
final db = await database;
await db.delete(
'contact_groups',
where: 'contact_id = ? AND group_id = ?',
whereArgs: [contactId, groupId],
);
} catch (e) {
throw ContactGroupException('从分组移除联系人失败: $e');
}
}
// 获取联系人所属分组
Future<List<ContactGroup>> getContactGroups(String contactId) async {
try {
final db = await database;
final List<Map<String, dynamic>> maps = await db.rawQuery('''
SELECT g.* FROM groups g
INNER JOIN contact_groups cg ON g.id = cg.group_id
WHERE cg.contact_id = ?
ORDER BY g.name
''', [contactId]);
return maps.map((map) => ContactGroup.fromJson(map)).toList();
} catch (e) {
throw ContactGroupException('获取联系人分组失败: $e');
}
}
// 获取分组中的联系人
Future<List<String>> getGroupContacts(String groupId) async {
try {
final db = await database;
final List<Map<String, dynamic>> maps = await db.query(
'contact_groups',
where: 'group_id = ?',
whereArgs: [groupId],
columns: ['contact_id'],
);
return maps.map((map) => map['contact_id'] as String).toList();
} catch (e) {
throw ContactGroupException('获取分组联系人失败: $e');
}
}
// 批量添加联系人到分组
Future<void> addContactsToGroup(List<String> contactIds, String groupId) async {
try {
final db = await database;
final batch = db.batch();
final now = DateTime.now().millisecondsSinceEpoch;
for (final contactId in contactIds) {
// 检查是否已存在
final existing = await db.query(
'contact_groups',
where: 'contact_id = ? AND group_id = ?',
whereArgs: [contactId, groupId],
);
if (existing.isEmpty) {
batch.insert('contact_groups', {
'contact_id': contactId,
'group_id': groupId,
'created_at': now,
});
}
}
await batch.commit();
} catch (e) {
throw ContactGroupException('批量添加联系人到分组失败: $e');
}
}
// 批量从分组移除联系人
Future<void> removeContactsFromGroup(List<String> contactIds, String groupId) async {
try {
final db = await database;
final placeholders = List.filled(contactIds.length, '?').join(',');
await db.delete(
'contact_groups',
where: 'group_id = ? AND contact_id IN ($placeholders)',
whereArgs: [groupId, ...contactIds],
);
} catch (e) {
throw ContactGroupException('批量从分组移除联系人失败: $e');
}
}
// 获取分组统计信息
Future<Map<String, int>> getGroupStatistics() async {
try {
final db = await database;
final List<Map<String, dynamic>> maps = await db.rawQuery('''
SELECT g.id, COUNT(cg.contact_id) as contact_count
FROM groups g
LEFT JOIN contact_groups cg ON g.id = cg.group_id
GROUP BY g.id
ORDER BY g.name
''');
final Map<String, int> statistics = {};
for (final map in maps) {
statistics[map['id'] as String] = map['contact_count'] as int;
}
return statistics;
} catch (e) {
throw ContactGroupException('获取分组统计信息失败: $e');
}
}
// 释放资源
void dispose() {
_groupsStreamController.close();
}
}
// 联系人分组异常
class ContactGroupException implements Exception {
final String message;
ContactGroupException(this.message);
@override
String toString() => message;
}第五步:创建联系人备份恢复服务
dart
// lib/services/contact_backup_service.dart
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
import 'package:file_picker/file_picker.dart';
import '../models/contact.dart';
class ContactBackupService {
static final ContactBackupService _instance = ContactBackupService._internal();
factory ContactBackupService() => _instance;
ContactBackupService._internal();
// 备份联系人到JSON文件
Future<File> backupContactsToJson(List<Contact> contacts, {String? filename}) async {
try {
final backupData = {
'version': '1.0',
'exportedAt': DateTime.now().toIso8601String(),
'contacts': contacts.map((contact) => contact.toJson()).toList(),
};
final jsonString = const JsonEncoder.withIndent(' ').convert(backupData);
final defaultFilename = filename ?? 'contacts_backup_${DateTime.now().millisecondsSinceEpoch}.json';
final file = await _saveFile(defaultFilename, jsonString);
return file;
} catch (e) {
throw ContactBackupException('备份联系人失败: $e');
}
}
// 备份联系人到CSV文件
Future<File> backupContactsToCsv(List<Contact> contacts, {String? filename}) async {
try {
final buffer = StringBuffer();
// CSV头部
buffer.writeln('姓名,电话,邮箱,公司,职位,地址,生日,备注');
// 数据行
for (final contact in contacts) {
final name = _escapeCsvField(contact.displayName);
final phones = contact.phones.map((p) => _escapeCsvField(p.number)).join(';');
final emails = contact.emails.map((e) => _escapeCsvField(e.address)).join(';');
final company = _escapeCsvField(contact.company?.name ?? '');
final title = _escapeCsvField(contact.company?.title ?? '');
final address = contact.address != null
? _escapeCsvField('${contact.address!.street}, ${contact.address!.city}')
: '';
final birthday = contact.birthday != null
? _escapeCsvField('${contact.birthday!.year}-${contact.birthday!.month}-${contact.birthday!.day}')
: '';
final notes = _escapeCsvField(contact.notes ?? '');
buffer.writeln('$name,$phones,$emails,$company,$title,$address,$birthday,$notes');
}
final defaultFilename = filename ?? 'contacts_backup_${DateTime.now().millisecondsSinceEpoch}.csv';
final file = await _saveFile(defaultFilename, buffer.toString());
return file;
} catch (e) {
throw ContactBackupException('备份联系人到CSV失败: $e');
}
}
// 备份联系人到VCard文件
Future<File> backupContactsToVCard(List<Contact> contacts, {String? filename}) async {
try {
final buffer = StringBuffer();
for (final contact in contacts) {
buffer.writeln('BEGIN:VCARD');
buffer.writeln('VERSION:3.0');
buffer.writeln('FN:${contact.displayName}');
buffer.writeln('N:${contact.lastName};${contact.firstName};;');
// 电话号码
for (final phone in contact.phones) {
final type = _getVCardPhoneType(phone.label);
buffer.writeln('TEL;TYPE=$type:${phone.number}');
}
// 邮箱地址
for (final email in contact.emails) {
final type = _getVCardEmailType(email.label);
buffer.writeln('EMAIL;TYPE=$type:${email.address}');
}
// 公司信息
if (contact.company != null) {
buffer.writeln('ORG:${contact.company!.name}');
if (contact.company!.title != null) {
buffer.writeln('TITLE:${contact.company!.title}');
}
}
// 地址
if (contact.address != null) {
final addr = contact.address!;
buffer.writeln('ADR:;;${addr.street};${addr.city};${addr.state};${addr.postalCode};${addr.country}');
}
// 生日
if (contact.birthday != null) {
final birthday = contact.birthday!;
buffer.writeln('BDAY:${birthday.year}${birthday.month.toString().padLeft(2, '0')}${birthday.day.toString().padLeft(2, '0')}');
}
// 备注
if (contact.notes != null && contact.notes!.isNotEmpty) {
buffer.writeln('NOTE:${contact.notes}');
}
buffer.writeln('END:VCARD');
buffer.writeln('');
}
final defaultFilename = filename ?? 'contacts_backup_${DateTime.now().millisecondsSinceEpoch}.vcf';
final file = await _saveFile(defaultFilename, buffer.toString());
return file;
} catch (e) {
throw ContactBackupException('备份联系人到VCard失败: $e');
}
}
// 从JSON文件恢复联系人
Future<List<Contact>> restoreContactsFromJson() async {
try {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['json'],
);
if (result == null || result.files.isEmpty) {
throw ContactBackupException('未选择文件');
}
final file = File(result.files.first.path!);
final content = await file.readAsString();
final jsonData = jsonDecode(content) as Map<String, dynamic>;
if (jsonData['contacts'] == null) {
throw ContactBackupException('无效的备份文件格式');
}
final contactsData = jsonData['contacts'] as List<dynamic>;
final contacts = contactsData.map((data) => Contact.fromJson(data as Map<String, dynamic>)).toList();
return contacts;
} catch (e) {
throw ContactBackupException('从JSON恢复联系人失败: $e');
}
}
// 从CSV文件恢复联系人
Future<List<Contact>> restoreContactsFromCsv() async {
try {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['csv'],
);
if (result == null || result.files.isEmpty) {
throw ContactBackupException('未选择文件');
}
final file = File(result.files.first.path!);
final lines = await file.readAsLines();
if (lines.isEmpty) {
throw ContactBackupException('空文件');
}
// 跳过头部行
final dataLines = lines.skip(1);
final contacts = <Contact>[];
for (final line in dataLines) {
if (line.trim().isEmpty) continue;
final fields = _parseCsvLine(line);
if (fields.length < 8) continue;
final name = fields[0];
final phonesStr = fields[1];
final emailsStr = fields[2];
final company = fields[3];
final title = fields[4];
final addressStr = fields[5];
final birthdayStr = fields[6];
final notes = fields[7];
// 解析电话号码
final phones = phonesStr.split(';')
.where((p) => p.isNotEmpty)
.map((p) => PhoneNumber(number: p, label: 'mobile'))
.toList();
// 解析邮箱地址
final emails = emailsStr.split(';')
.where((e) => e.isNotEmpty)
.map((e) => Email(address: e, label: 'home'))
.toList();
// 解析地址
PostalAddress? address;
if (addressStr.isNotEmpty) {
final addressParts = addressStr.split(',');
address = PostalAddress(
street: addressParts.isNotEmpty ? addressParts[0] : '',
city: addressParts.length > 1 ? addressParts[1] : '',
state: addressParts.length > 2 ? addressParts[2] : '',
postalCode: addressParts.length > 3 ? addressParts[3] : '',
country: addressParts.length > 4 ? addressParts[4] : '',
);
}
// 解析生日
DateTime? birthday;
if (birthdayStr.isNotEmpty) {
try {
final parts = birthdayStr.split('-');
if (parts.length == 3) {
birthday = DateTime(
int.parse(parts[0]),
int.parse(parts[1]),
int.parse(parts[2]),
);
}
} catch (e) {
// 忽略生日解析错误
}
}
// 创建联系人对象
final contact = Contact(
id: DateTime.now().millisecondsSinceEpoch.toString(),
firstName: name.split(' ').first,
lastName: name.split(' ').skip(1).join(' '),
displayName: name,
phones: phones,
emails: emails,
address: address,
company: company.isNotEmpty ? Company(name: company, title: title.isNotEmpty ? title : null) : null,
birthday: birthday,
notes: notes.isNotEmpty ? notes : null,
);
contacts.add(contact);
}
return contacts;
} catch (e) {
throw ContactBackupException('从CSV恢复联系人失败: $e');
}
}
// 从VCard文件恢复联系人
Future<List<Contact>> restoreContactsFromVCard() async {
try {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['vcf'],
);
if (result == null || result.files.isEmpty) {
throw ContactBackupException('未选择文件');
}
final file = File(result.files.first.path!);
final content = await file.readAsString();
return _parseVCardContent(content);
} catch (e) {
throw ContactBackupException('从VCard恢复联系人失败: $e');
}
}
// 分享备份文件
Future<void> shareBackupFile(File file) async {
try {
await Share.shareXFiles([XFile(file.path)], text: '分享联系人备份文件');
} catch (e) {
throw ContactBackupException('分享备份文件失败: $e');
}
}
// 保存文件
Future<File> _saveFile(String filename, String content) async {
final directory = await getApplicationDocumentsDirectory();
final file = File('${directory.path}/$filename');
return await file.writeAsString(content);
}
// 转义CSV字段
String _escapeCsvField(String field) {
if (field.contains(',') || field.contains('"') || field.contains('\n')) {
return '"${field.replaceAll('"', '""')}"';
}
return field;
}
// 解析CSV行
List<String> _parseCsvLine(String line) {
final fields = <String>[];
bool inQuotes = false;
String currentField = '';
for (int i = 0; i < line.length; i++) {
final char = line[i];
if (char == '"') {
if (inQuotes && i + 1 < line.length && line[i + 1] == '"') {
// 转义的引号
currentField += '"';
i++; // 跳过下一个引号
} else {
// 切换引号状态
inQuotes = !inQuotes;
}
} else if (char == ',' && !inQuotes) {
// 字段分隔符
fields.add(currentField);
currentField = '';
} else {
currentField += char;
}
}
// 添加最后一个字段
fields.add(currentField);
return fields;
}
// 获取VCard电话类型
String _getVCardPhoneType(String? label) {
switch (label?.toLowerCase()) {
case 'mobile':
case '手机':
return 'CELL';
case 'home':
case '住宅':
return 'HOME';
case 'work':
case '工作':
return 'WORK';
default:
return 'CELL';
}
}
// 获取VCard邮箱类型
String _getVCardEmailType(String? label) {
switch (label?.toLowerCase()) {
case 'home':
case '住宅':
return 'HOME';
case 'work':
case '工作':
return 'WORK';
default:
return 'HOME';
}
}
// 解析VCard内容
List<Contact> _parseVCardContent(String content) {
final contacts = <Contact>[];
final vcardBlocks = content.split('BEGIN:VCARD').skip(1);
for (final block in vcardBlocks) {
if (!block.contains('END:VCARD')) continue;
final vcardContent = block.split('END:VCARD').first;
final lines = vcardContent.split('\n').where((line) => line.isNotEmpty).toList();
String? fn;
String? n;
final phones = <PhoneNumber>[];
final emails = <Email>[];
String? org;
String? title;
String? adr;
String? bday;
String? note;
for (final line in lines) {
final parts = line.split(':');
if (parts.length < 2) continue;
final key = parts[0];
final value = parts.sublist(1).join(':');
switch (key) {
case 'FN':
fn = value;
break;
case 'N':
n = value;
break;
case 'TEL':
final phoneType = _extractVCardType(key);
phones.add(PhoneNumber(number: value, label: phoneType));
break;
case 'EMAIL':
final emailType = _extractVCardType(key);
emails.add(Email(address: value, label: emailType));
break;
case 'ORG':
org = value;
break;
case 'TITLE':
title = value;
break;
case 'ADR':
adr = value;
break;
case 'BDAY':
bday = value;
break;
case 'NOTE':
note = value;
break;
}
}
if (fn == null && n == null) continue;
// 解析姓名
String firstName = '';
String lastName = '';
String displayName = fn ?? '';
if (n != null) {
final nameParts = n.split(';');
lastName = nameParts.isNotEmpty ? nameParts[0] : '';
firstName = nameParts.length > 1 ? nameParts[1] : '';
}
if (displayName.isEmpty) {
displayName = '$firstName $lastName'.trim();
}
// 解析地址
PostalAddress? address;
if (adr != null) {
final addrParts = adr.split(';');
address = PostalAddress(
street: addrParts.length > 2 ? addrParts[2] : '',
city: addrParts.length > 3 ? addrParts[3] : '',
state: addrParts.length > 4 ? addrParts[4] : '',
postalCode: addrParts.length > 5 ? addrParts[5] : '',
country: addrParts.length > 6 ? addrParts[6] : '',
);
}
// 解析生日
DateTime? birthday;
if (bday != null && bday.length >= 8) {
try {
birthday = DateTime(
int.parse(bday.substring(0, 4)),
int.parse(bday.substring(4, 6)),
int.parse(bday.substring(6, 8)),
);
} catch (e) {
// 忽略生日解析错误
}
}
final contact = Contact(
id: DateTime.now().millisecondsSinceEpoch.toString(),
firstName: firstName,
lastName: lastName,
displayName: displayName,
phones: phones,
emails: emails,
address: address,
company: org != null ? Company(name: org, title: title) : null,
birthday: birthday,
notes: note,
);
contacts.add(contact);
}
return contacts;
}
// 提取VCard类型
String _extractVCardType(String line) {
final typeMatch = RegExp(r'TYPE=([^:;]+)').firstMatch(line);
if (typeMatch != null) {
return typeMatch.group(1)!.toLowerCase();
}
return 'home';
}
}
// 联系人备份异常
class ContactBackupException implements Exception {
final String message;
ContactBackupException(this.message);
@override
String toString() => message;
}第六步:创建联系人 UI 组件
dart
// lib/widgets/contact_list_widget.dart
import 'package:flutter/material.dart';
import 'package:flutter_contacts/flutter_contacts.dart';
import '../models/contact.dart';
import '../services/contact_service.dart';
class ContactListWidget extends StatefulWidget {
final Function(Contact) onContactTap;
final Function(Contact) onContactLongPress;
final bool showSearchBar;
final bool enableSelection;
final Function(List<Contact>)? onSelectionChanged;
const ContactListWidget({
Key? key,
required this.onContactTap,
required this.onContactLongPress,
this.showSearchBar = true,
this.enableSelection = false,
this.onSelectionChanged,
}) : super(key: key);
@override
_ContactListWidgetState createState() => _ContactListWidgetState();
}
class _ContactListWidgetState extends State<ContactListWidget> {
final ContactService _contactService = ContactService();
final TextEditingController _searchController = TextEditingController();
final Set<String> _selectedContactIds = {};
List<Contact> _contacts = [];
List<Contact> _filteredContacts = [];
Map<String, List<Contact>> _groupedContacts = {};
bool _isLoading = false;
String? _error;
@override
void initState() {
super.initState();
_initializeContacts();
_searchController.addListener(_onSearchChanged);
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<void> _initializeContacts() async {
setState(() => _isLoading = true);
try {
await _contactService.initialize();
_contactService.contactsStream.listen((contacts) {
setState(() {
_contacts = contacts;
_filteredContacts = contacts;
_groupedContacts = _contactService.groupContactsByFirstLetter();
});
});
} catch (e) {
setState(() => _error = e.toString());
} finally {
setState(() => _isLoading = false);
}
}
void _onSearchChanged() {
final query = _searchController.text;
setState(() {
_filteredContacts = _contactService.searchContacts(query);
_groupedContacts = _groupContactsByFirstLetter(_filteredContacts);
});
}
Map<String, List<Contact>> _groupContactsByFirstLetter(List<Contact> contacts) {
final Map<String, List<Contact>> groupedContacts = {};
for (final contact in contacts) {
String firstLetter = '#';
if (contact.displayName.isNotEmpty) {
firstLetter = contact.displayName[0].toUpperCase();
}
if (!groupedContacts.containsKey(firstLetter)) {
groupedContacts[firstLetter] = [];
}
groupedContacts[firstLetter]!.add(contact);
}
return groupedContacts;
}
void _onContactTap(Contact contact) {
if (widget.enableSelection) {
_toggleSelection(contact);
} else {
widget.onContactTap(contact);
}
}
void _onContactLongPress(Contact contact) {
if (widget.enableSelection) {
_toggleSelection(contact);
} else {
widget.onContactLongPress(contact);
}
}
void _toggleSelection(Contact contact) {
setState(() {
if (_selectedContactIds.contains(contact.id)) {
_selectedContactIds.remove(contact.id);
} else {
_selectedContactIds.add(contact.id);
}
});
widget.onSelectionChanged?.call(_selectedContactIds
.map((id) => _contacts.firstWhere((c) => c.id == id))
.toList());
}
void _clearSelection() {
setState(() {
_selectedContactIds.clear();
});
widget.onSelectionChanged?.call([]);
}
@override
Widget build(BuildContext context) {
return Column(
children: [
// 搜索栏
if (widget.showSearchBar)
Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: '搜索联系人',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () => _searchController.clear(),
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
// 选择操作栏
if (widget.enableSelection && _selectedContactIds.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
color: Theme.of(context).primaryColor.withOpacity(0.1),
child: Row(
children: [
Text(
'已选择 ${_selectedContactIds.length} 个联系人',
style: const TextStyle(fontWeight: FontWeight.bold),
),
const Spacer(),
TextButton(
onPressed: _clearSelection,
child: const Text('取消选择'),
),
],
),
),
// 联系人列表
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: _error != null
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error, size: 64, color: Colors.red),
const SizedBox(height: 16),
Text('加载失败: $_error'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _initializeContacts,
child: const Text('重试'),
),
],
),
)
: _filteredContacts.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.contacts, size: 64, color: Colors.grey),
const SizedBox(height: 16),
Text(
_searchController.text.isNotEmpty
? '未找到匹配的联系人'
: '暂无联系人',
style: const TextStyle(color: Colors.grey),
),
],
),
)
: ListView.builder(
itemCount: _groupedContacts.keys.length,
itemBuilder: (context, index) {
final letter = _groupedContacts.keys.elementAt(index);
final contacts = _groupedContacts[letter]!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 字母标题
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
letter,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.blue,
),
),
),
// 联系人列表
...contacts.map((contact) => ContactTile(
contact: contact,
isSelected: _selectedContactIds.contains(contact.id),
onTap: () => _onContactTap(contact),
onLongPress: () => _onContactLongPress(contact),
)),
],
);
},
),
),
],
);
}
}
// 联系人卡片组件
class ContactTile extends StatelessWidget {
final Contact contact;
final bool isSelected;
final VoidCallback onTap;
final VoidCallback onLongPress;
const ContactTile({
Key? key,
required this.contact,
this.isSelected = false,
required this.onTap,
required this.onLongPress,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return ListTile(
leading: CircleAvatar(
backgroundImage: contact.avatar != null ? NetworkImage(contact.avatar!) : null,
child: contact.avatar == null
? Text(
contact.displayName.isNotEmpty
? contact.displayName[0].toUpperCase()
: '?',
style: const TextStyle(fontWeight: FontWeight.bold),
)
: null,
),
title: Text(contact.displayName),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (contact.phones.isNotEmpty)
Text(contact.phones.first.number),
if (contact.emails.isNotEmpty)
Text(contact.emails.first.address),
],
),
trailing: isSelected
? const Icon(Icons.check_circle, color: Colors.blue)
: const Icon(Icons.chevron_right),
onTap: onTap,
onLongPress: onLongPress,
);
}
}
// 联系人详情页面
class ContactDetailPage extends StatefulWidget {
final Contact contact;
final Function(Contact) onContactUpdated;
final Function(String) onContactDeleted;
const ContactDetailPage({
Key? key,
required this.contact,
required this.onContactUpdated,
required this.onContactDeleted,
}) : super(key: key);
@override
_ContactDetailPageState createState() => _ContactDetailPageState();
}
class _ContactDetailPageState extends State<ContactDetailPage> {
late Contact _contact;
bool _isEditing = false;
final _formKey = GlobalKey<FormState>();
final _firstNameController = TextEditingController();
final _lastNameController = TextEditingController();
final _companyController = TextEditingController();
final _titleController = TextEditingController();
final _notesController = TextEditingController();
@override
void initState() {
super.initState();
_contact = widget.contact;
_initializeControllers();
}
@override
void dispose() {
_firstNameController.dispose();
_lastNameController.dispose();
_companyController.dispose();
_titleController.dispose();
_notesController.dispose();
super.dispose();
}
void _initializeControllers() {
_firstNameController.text = _contact.firstName;
_lastNameController.text = _contact.lastName;
_companyController.text = _contact.company?.name ?? '';
_titleController.text = _contact.company?.title ?? '';
_notesController.text = _contact.notes ?? '';
}
void _toggleEdit() {
if (_isEditing) {
_saveContact();
} else {
setState(() => _isEditing = true);
}
}
Future<void> _saveContact() async {
if (!_formKey.currentState!.validate()) return;
try {
final updatedContact = _contact.copyWith(
firstName: _firstNameController.text,
lastName: _lastNameController.text,
company: _companyController.text.isNotEmpty
? Company(
name: _companyController.text,
title: _titleController.text.isNotEmpty ? _titleController.text : null,
)
: null,
notes: _notesController.text.isNotEmpty ? _notesController.text : null,
);
final contactService = ContactService();
final savedContact = await contactService.updateContact(updatedContact);
setState(() {
_contact = savedContact;
_isEditing = false;
});
widget.onContactUpdated(savedContact);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('联系人已更新')),
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('更新失败: $e')),
);
}
}
Future<void> _deleteContact() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('确认删除'),
content: Text('确定要删除联系人 ${_contact.displayName} 吗?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('取消'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('删除'),
),
],
),
);
if (confirmed == true) {
try {
final contactService = ContactService();
await contactService.deleteContact(_contact.id);
widget.onContactDeleted(_contact.id);
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('联系人已删除')),
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('删除失败: $e')),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(_isEditing ? '编辑联系人' : '联系人详情'),
actions: [
if (!_isEditing)
IconButton(
icon: const Icon(Icons.edit),
onPressed: _toggleEdit,
),
if (_isEditing)
TextButton(
onPressed: _toggleEdit,
child: const Text('保存'),
),
if (!_isEditing)
IconButton(
icon: const Icon(Icons.delete),
onPressed: _deleteContact,
),
],
),
body: _isEditing ? _buildEditForm() : _buildContactDetails(),
);
}
Widget _buildContactDetails() {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 头像和姓名
Center(
child: Column(
children: [
CircleAvatar(
radius: 50,
backgroundImage: _contact.avatar != null ? NetworkImage(_contact.avatar!) : null,
child: _contact.avatar == null
? Text(
_contact.displayName.isNotEmpty
? _contact.displayName[0].toUpperCase()
: '?',
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
)
: null,
),
const SizedBox(height: 16),
Text(
_contact.displayName,
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
],
),
),
const SizedBox(height: 32),
// 电话号码
if (_contact.phones.isNotEmpty) ...[
const SectionTitle(title: '电话号码'),
..._contact.phones.map((phone) => ListTile(
leading: const Icon(Icons.phone),
title: Text(phone.number),
subtitle: Text(phone.label),
onTap: () => _launchPhone(phone.number),
)),
const SizedBox(height: 16),
],
// 邮箱地址
if (_contact.emails.isNotEmpty) ...[
const SectionTitle(title: '邮箱地址'),
..._contact.emails.map((email) => ListTile(
leading: const Icon(Icons.email),
title: Text(email.address),
subtitle: Text(email.label),
onTap: () => _launchEmail(email.address),
)),
const SizedBox(height: 16),
],
// 公司信息
if (_contact.company != null) ...[
const SectionTitle(title: '公司信息'),
ListTile(
leading: const Icon(Icons.business),
title: Text(_contact.company!.name),
subtitle: _contact.company!.title,
),
const SizedBox(height: 16),
],
// 地址
if (_contact.address != null) ...[
const SectionTitle(title: '地址'),
ListTile(
leading: const Icon(Icons.location_on),
title: Text('${_contact.address!.street}, ${_contact.address!.city}'),
subtitle: Text('${_contact.address!.state}, ${_contact.address!.postalCode}'),
),
const SizedBox(height: 16),
],
// 生日
if (_contact.birthday != null) ...[
const SectionTitle(title: '生日'),
ListTile(
leading: const Icon(Icons.cake),
title: Text('${_contact.birthday!.year}-${_contact.birthday!.month}-${_contact.birthday!.day}'),
),
const SizedBox(height: 16),
],
// 备注
if (_contact.notes != null && _contact.notes!.isNotEmpty) ...[
const SectionTitle(title: '备注'),
ListTile(
leading: const Icon(Icons.note),
title: Text(_contact.notes!),
),
],
],
),
);
}
Widget _buildEditForm() {
return Form(
key: _formKey,
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
children: [
TextFormField(
controller: _firstNameController,
decoration: const InputDecoration(labelText: '名字'),
validator: (value) {
if (value == null || value.isEmpty) {
return '请输入名字';
}
return null;
},
),
TextFormField(
controller: _lastNameController,
decoration: const InputDecoration(labelText: '姓氏'),
),
TextFormField(
controller: _companyController,
decoration: const InputDecoration(labelText: '公司'),
),
TextFormField(
controller: _titleController,
decoration: const InputDecoration(labelText: '职位'),
),
TextFormField(
controller: _notesController,
decoration: const InputDecoration(labelText: '备注'),
maxLines: 3,
),
],
),
),
);
}
Future<void> _launchPhone(String phoneNumber) async {
final Uri phoneUri = Uri(scheme: 'tel', path: phoneNumber);
// 这里可以使用 url_launcher 来拨打电话
}
Future<void> _launchEmail(String emailAddress) async {
final Uri emailUri = Uri(scheme: 'mailto', path: emailAddress);
// 这里可以使用 url_launcher 来发送邮件
}
}
// 区块标题组件
class SectionTitle extends StatelessWidget {
final String title;
const SectionTitle({Key? key, required this.title}) : super(key: key);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.blue,
),
),
);
}
}第七步:创建主应用界面
dart
// lib/main.dart
import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';
import 'widgets/contact_list_widget.dart';
import 'widgets/contact_detail_page.dart';
import 'models/contact.dart';
import 'services/contact_service.dart';
void main() {
runApp(const ContactHubApp());
}
class ContactHubApp extends StatelessWidget {
const ContactHubApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'ContactHub',
theme: ThemeData(
primarySwatch: Colors.blue,
brightness: Brightness.light,
),
home: const MainScreen(),
);
}
}
class MainScreen extends StatefulWidget {
const MainScreen({Key? key}) : super(key: key);
@override
_MainScreenState createState() => _MainScreenState();
}
class _MainScreenState extends State<MainScreen> {
final ContactService _contactService = ContactService();
bool _permissionsGranted = false;
bool _isLoading = false;
@override
void initState() {
super.initState();
_checkPermissions();
}
Future<void> _checkPermissions() async {
setState(() => _isLoading = true);
try {
final contactsPermission = await Permission.contacts.status;
if (contactsPermission.isGranted) {
setState(() => _permissionsGranted = true);
} else {
setState(() => _permissionsGranted = false);
}
} catch (e) {
setState(() => _permissionsGranted = false);
} finally {
setState(() => _isLoading = false);
}
}
Future<void> _requestPermissions() async {
try {
final contactsPermission = await Permission.contacts.request();
if (contactsPermission.isGranted) {
setState(() => _permissionsGranted = true);
} else {
_showPermissionDeniedDialog('联系人权限被拒绝,应用无法访问您的联系人');
}
} catch (e) {
_showErrorSnackBar('请求权限失败: $e');
}
}
void _showPermissionDeniedDialog(String message) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('权限被拒绝'),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('确定'),
),
TextButton(
onPressed: () {
Navigator.of(context).pop();
openAppSettings();
},
child: const Text('打开设置'),
),
],
),
);
}
void _showErrorSnackBar(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Colors.red,
),
);
}
void _onContactTap(Contact contact) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => ContactDetailPage(
contact: contact,
onContactUpdated: (updatedContact) {
_contactService.refreshContacts();
},
onContactDeleted: (contactId) {
_contactService.refreshContacts();
},
),
),
);
}
void _onContactLongPress(Contact contact) {
_showContactOptions(contact);
}
void _showContactOptions(Contact contact) {
showModalBottomSheet(
context: context,
builder: (context) => Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.phone),
title: const Text('拨打电话'),
onTap: () {
Navigator.of(context).pop();
if (contact.phones.isNotEmpty) {
_launchPhone(contact.phones.first.number);
}
},
),
ListTile(
leading: const Icon(Icons.email),
title: const Text('发送邮件'),
onTap: () {
Navigator.of(context).pop();
if (contact.emails.isNotEmpty) {
_launchEmail(contact.emails.first.address);
}
},
),
ListTile(
leading: const Icon(Icons.message),
title: const Text('发送短信'),
onTap: () {
Navigator.of(context).pop();
if (contact.phones.isNotEmpty) {
_launchSms(contact.phones.first.number);
}
},
),
ListTile(
leading: const Icon(Icons.edit),
title: const Text('编辑联系人'),
onTap: () {
Navigator.of(context).pop();
_onContactTap(contact);
},
),
],
),
);
}
Future<void> _launchPhone(String phoneNumber) async {
final Uri phoneUri = Uri(scheme: 'tel', path: phoneNumber);
// 这里可以使用 url_launcher 来拨打电话
}
Future<void> _launchEmail(String emailAddress) async {
final Uri emailUri = Uri(scheme: 'mailto', path: emailAddress);
// 这里可以使用 url_launcher 来发送邮件
}
Future<void> _launchSms(String phoneNumber) async {
final Uri smsUri = Uri(scheme: 'sms', path: phoneNumber);
// 这里可以使用 url_launcher 来发送短信
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
if (!_permissionsGranted) {
return Scaffold(
appBar: AppBar(
title: const Text('ContactHub'),
backgroundColor: Colors.blue,
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.contacts,
size: 64,
color: Colors.grey,
),
const SizedBox(height: 16),
const Text(
'需要联系人权限',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
const Text(
'ContactHub需要访问您的联系人来提供联系人管理服务',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 16),
),
const SizedBox(height: 32),
ElevatedButton(
onPressed: _requestPermissions,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
),
child: const Text(
'授予权限',
style: TextStyle(fontSize: 18),
),
),
],
),
),
);
}
return Scaffold(
appBar: AppBar(
title: const Text('ContactHub'),
backgroundColor: Colors.blue,
actions: [
IconButton(
icon: const Icon(Icons.group),
onPressed: () => _showGroupsPage(),
tooltip: '分组管理',
),
IconButton(
icon: const Icon(Icons.backup),
onPressed: () => _showBackupPage(),
tooltip: '备份恢复',
),
],
),
body: ContactListWidget(
onContactTap: _onContactTap,
onContactLongPress: _onContactLongPress,
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showAddContactDialog(),
child: const Icon(Icons.add),
),
);
}
void _showGroupsPage() {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const GroupsPage(),
),
);
}
void _showBackupPage() {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const BackupPage(),
),
);
}
void _showAddContactDialog() {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const AddContactPage(),
),
);
}
}
// 分组管理页面
class GroupsPage extends StatelessWidget {
const GroupsPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('分组管理'),
backgroundColor: Colors.blue,
),
body: const Center(
child: Text('分组管理功能待实现'),
),
);
}
}
// 备份恢复页面
class BackupPage extends StatelessWidget {
const BackupPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('备份恢复'),
backgroundColor: Colors.blue,
),
body: const Center(
child: Text('备份恢复功能待实现'),
),
);
}
}
// 添加联系人页面
class AddContactPage extends StatelessWidget {
const AddContactPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('添加联系人'),
backgroundColor: Colors.blue,
),
body: const Center(
child: Text('添加联系人功能待实现'),
),
);
}
}高级功能实现
1. 智能联系人推荐
dart
// lib/services/contact_recommendation_service.dart
import 'dart:async';
import 'dart:math';
import '../models/contact.dart';
import '../services/contact_service.dart';
class ContactRecommendationService {
static final ContactRecommendationService _instance = ContactRecommendationService._internal();
factory ContactRecommendationService() => _instance;
ContactRecommendationService._internal();
final ContactService _contactService = ContactService();
// 获取推荐联系人
Future<List<Contact>> getRecommendedContacts({int limit = 10}) async {
try {
final allContacts = _contactService.contacts;
final recommendations = <Contact>[];
// 基于通话频率推荐
final frequentContacts = await _getFrequentContacts();
recommendations.addAll(frequentContacts);
// 基于最近通话推荐
final recentContacts = await _getRecentContacts();
recommendations.addAll(recentContacts);
// 基于分组推荐
final groupContacts = await _getGroupBasedRecommendations();
recommendations.addAll(groupContacts);
// 去重并限制数量
final uniqueRecommendations = <String, Contact>{};
for (final contact in recommendations) {
uniqueRecommendations[contact.id] = contact;
}
return uniqueRecommendations.values.take(limit).toList();
} catch (e) {
throw ContactRecommendationException('获取推荐联系人失败: $e');
}
}
// 获取常用联系人
Future<List<Contact>> _getFrequentContacts() async {
try {
final callRecords = await _contactService.getCallRecords();
final contactCallCount = <String, int>{};
// 统计每个联系人的通话次数
for (final record in callRecords) {
if (record.contactId.isNotEmpty) {
contactCallCount[record.contactId] = (contactCallCount[record.contactId] ?? 0) + 1;
}
}
// 按通话次数排序
final sortedEntries = contactCallCount.entries.toList()
..sort((a, b) => b.value.compareTo(a.value));
// 获取前5个常用联系人
final frequentContactIds = sortedEntries.take(5).map((e) => e.key).toList();
final allContacts = _contactService.contacts;
return allContacts.where((contact) => frequentContactIds.contains(contact.id)).toList();
} catch (e) {
return [];
}
}
// 获取最近联系人
Future<List<Contact>> _getRecentContacts() async {
try {
final callRecords = await _contactService.getCallRecords();
final recentContactIds = callRecords
.where((record) => record.contactId.isNotEmpty)
.take(5)
.map((record) => record.contactId)
.toSet();
final allContacts = _contactService.contacts;
return allContacts.where((contact) => recentContactIds.contains(contact.id)).toList();
} catch (e) {
return [];
}
}
// 基于分组推荐
Future<List<Contact>> _getGroupBasedRecommendations() async {
try {
// 这里可以实现基于分组的推荐逻辑
// 例如:推荐同一分组的其他联系人
return [];
} catch (e) {
return [];
}
}
// 获取相似联系人
Future<List<Contact>> getSimilarContacts(Contact contact, {int limit = 5}) async {
try {
final allContacts = _contactService.contacts;
final similarities = <String, double>{};
for (final otherContact in allContacts) {
if (otherContact.id == contact.id) continue;
final similarity = _calculateSimilarity(contact, otherContact);
similarities[otherContact.id] = similarity;
}
// 按相似度排序
final sortedEntries = similarities.entries.toList()
..sort((a, b) => b.value.compareTo(a.value));
final similarContactIds = sortedEntries.take(limit).map((e) => e.key).toList();
return allContacts.where((c) => similarContactIds.contains(c.id)).toList();
} catch (e) {
throw ContactRecommendationException('获取相似联系人失败: $e');
}
}
// 计算联系人相似度
double _calculateSimilarity(Contact contact1, Contact contact2) {
double similarity = 0.0;
// 公司名称相似度
if (contact1.company?.name != null && contact2.company?.name != null) {
if (contact1.company!.name == contact2.company!.name) {
similarity += 0.3;
}
}
// 电话号码相似度
for (final phone1 in contact1.phones) {
for (final phone2 in contact2.phones) {
if (_isSimilarPhoneNumber(phone1.number, phone2.number)) {
similarity += 0.4;
break;
}
}
}
// 邮箱地址相似度
for (final email1 in contact1.emails) {
for (final email2 in contact2.emails) {
if (_isSimilarEmail(email1.address, email2.address)) {
similarity += 0.3;
break;
}
}
}
return similarity;
}
// 判断电话号码是否相似
bool _isSimilarPhoneNumber(String phone1, String phone2) {
// 移除所有非数字字符
final cleanPhone1 = phone1.replaceAll(RegExp(r'[^0-9]'), '');
final cleanPhone2 = phone2.replaceAll(RegExp(r'[^0-9]'), '');
// 如果长度相差太大,不相似
if ((cleanPhone1.length - cleanPhone2.length).abs() > 2) {
return false;
}
// 检查后几位是否相同
final minLength = min(cleanPhone1.length, cleanPhone2.length);
final suffixLength = min(6, minLength);
return cleanPhone1.substring(cleanPhone1.length - suffixLength) ==
cleanPhone2.substring(cleanPhone2.length - suffixLength);
}
// 判断邮箱是否相似
bool _isSimilarEmail(String email1, String email2) {
// 获取邮箱域名
final domain1 = email1.split('@').last.toLowerCase();
final domain2 = email2.split('@').last.toLowerCase();
// 如果域名相同,认为相似
return domain1 == domain2;
}
}
// 联系人推荐异常
class ContactRecommendationException implements Exception {
final String message;
ContactRecommendationException(this.message);
@override
String toString() => message;
}2. 联系人数据分析
dart
// lib/services/contact_analytics_service.dart
import 'dart:math';
import '../models/contact.dart';
import '../services/contact_service.dart';
class ContactAnalyticsService {
static final ContactAnalyticsService _instance = ContactAnalyticsService._internal();
factory ContactAnalyticsService() => _instance;
ContactAnalyticsService._internal();
final ContactService _contactService = ContactService();
// 获取联系人统计信息
Future<ContactStatistics> getContactStatistics() async {
try {
final contacts = _contactService.contacts;
// 基本统计
final totalContacts = contacts.length;
final contactsWithPhone = contacts.where((c) => c.phones.isNotEmpty).length;
final contactsWithEmail = contacts.where((c) => c.emails.isNotEmpty).length;
final contactsWithCompany = contacts.where((c) => c.company != null).length;
final contactsWithBirthday = contacts.where((c) => c.birthday != null).length;
// 电话号码统计
final totalPhoneNumbers = contacts.fold<int>(0, (sum, contact) => sum + contact.phones.length);
final mobilePhones = contacts.fold<int>(0, (sum, contact) =>
sum + contact.phones.where((p) => _isMobilePhone(p.number)).length);
final workPhones = contacts.fold<int>(0, (sum, contact) =>
sum + contact.phones.where((p) => _isWorkPhone(p.number)).length);
// 邮箱统计
final totalEmails = contacts.fold<int>(0, (sum, contact) => sum + contact.emails.length);
final workEmails = contacts.fold<int>(0, (sum, contact) =>
sum + contact.emails.where((e) => _isWorkEmail(e.address)).length);
final personalEmails = contacts.fold<int>(0, (sum, contact) =>
sum + contact.emails.where((e) => _isPersonalEmail(e.address)).length);
// 公司统计
final companies = <String, int>{};
for (final contact in contacts) {
if (contact.company?.name != null) {
final company = contact.company!.name;
companies[company] = (companies[company] ?? 0) + 1;
}
}
// 生日月份分布
final birthdayMonths = <int, int>{};
for (final contact in contacts) {
if (contact.birthday != null) {
final month = contact.birthday!.month;
birthdayMonths[month] = (birthdayMonths[month] ?? 0) + 1;
}
}
return ContactStatistics(
totalContacts: totalContacts,
contactsWithPhone: contactsWithPhone,
contactsWithEmail: contactsWithEmail,
contactsWithCompany: contactsWithCompany,
contactsWithBirthday: contactsWithBirthday,
totalPhoneNumbers: totalPhoneNumbers,
mobilePhones: mobilePhones,
workPhones: workPhones,
totalEmails: totalEmails,
workEmails: workEmails,
personalEmails: personalEmails,
topCompanies: _getTopItems(companies, 10),
birthdayMonthDistribution: birthdayMonths,
);
} catch (e) {
throw ContactAnalyticsException('获取联系人统计失败: $e');
}
}
// 获取联系人增长趋势
Future<List<ContactGrowthData>> getContactGrowthTrend() async {
try {
final contacts = _contactService.contacts;
final growthData = <ContactGrowthData>[];
// 按月份分组统计
final monthlyContacts = <DateTime, List<Contact>>{};
for (final contact in contacts) {
final month = DateTime(contact.createdAt.year, contact.createdAt.month, 1);
if (!monthlyContacts.containsKey(month)) {
monthlyContacts[month] = [];
}
monthlyContacts[month]!.add(contact);
}
// 计算累计增长
final sortedMonths = monthlyContacts.keys.toList()..sort();
int cumulativeCount = 0;
for (final month in sortedMonths) {
cumulativeCount += monthlyContacts[month]!.length;
growthData.add(ContactGrowthData(
month: month,
newContacts: monthlyContacts[month]!.length,
totalContacts: cumulativeCount,
));
}
return growthData;
} catch (e) {
throw ContactAnalyticsException('获取联系人增长趋势失败: $e');
}
}
// 获取联系人活跃度分析
Future<ContactActivityAnalysis> getActivityAnalysis() async {
try {
final callRecords = await _contactService.getCallRecords();
final contacts = _contactService.contacts;
// 统计每个联系人的活跃度
final contactActivity = <String, ContactActivity>{};
for (final record in callRecords) {
if (record.contactId.isEmpty) continue;
if (!contactActivity.containsKey(record.contactId)) {
final contact = contacts.firstWhere((c) => c.id == record.contactId, orElse: () => contacts.first);
contactActivity[record.contactId] = ContactActivity(
contactId: record.contactId,
contactName: contact.displayName,
totalCalls: 0,
incomingCalls: 0,
outgoingCalls: 0,
missedCalls: 0,
totalDuration: Duration.zero,
lastContact: record.timestamp,
);
}
final activity = contactActivity[record.contactId]!;
activity.totalCalls++;
activity.totalDuration += record.duration;
switch (record.callType) {
case CallType.incoming:
activity.incomingCalls++;
break;
case CallType.outgoing:
activity.outgoingCalls++;
break;
case CallType.missed:
activity.missedCalls++;
break;
}
if (record.timestamp.isAfter(activity.lastContact)) {
activity.lastContact = record.timestamp;
}
}
// 排序获取最活跃的联系人
final sortedActivities = contactActivity.values.toList()
..sort((a, b) => b.totalCalls.compareTo(a.totalCalls));
return ContactActivityAnalysis(
totalActivities: contactActivity.length,
mostActiveContacts: sortedActivities.take(10).toList(),
averageCallsPerContact: contactActivity.isEmpty ? 0.0 :
contactActivity.values.fold(0.0, (sum, activity) => sum + activity.totalCalls) / contactActivity.length,
);
} catch (e) {
throw ContactAnalyticsException('获取联系人活跃度分析失败: $e');
}
}
// 判断是否为手机号码
bool _isMobilePhone(String phoneNumber) {
final cleanPhone = phoneNumber.replaceAll(RegExp(r'[^0-9]'), '');
return cleanPhone.length == 11 && cleanPhone.startsWith('1');
}
// 判断是否为工作电话
bool _isWorkPhone(String phoneNumber) {
// 简化实现,可以根据实际需求调整
return phoneNumber.toLowerCase().contains('work') ||
phoneNumber.toLowerCase().contains('office');
}
// 判断是否为工作邮箱
bool _isWorkEmail(String email) {
final domain = email.split('@').last.toLowerCase();
return !domain.contains('gmail.com') &&
!domain.contains('yahoo.com') &&
!domain.contains('hotmail.com') &&
!domain.contains('qq.com') &&
!domain.contains('163.com');
}
// 判断是否为个人邮箱
bool _isPersonalEmail(String email) {
final domain = email.split('@').last.toLowerCase();
return domain.contains('gmail.com') ||
domain.contains('yahoo.com') ||
domain.contains('hotmail.com') ||
domain.contains('qq.com') ||
domain.contains('163.com');
}
// 获取前N个项目
Map<String, int> _getTopItems(Map<String, int> items, int count) {
final sortedEntries = items.entries.toList()
..sort((a, b) => b.value.compareTo(a.value));
return Map.fromEntries(sortedEntries.take(count));
}
}
// 联系人统计信息
class ContactStatistics {
final int totalContacts;
final int contactsWithPhone;
final int contactsWithEmail;
final int contactsWithCompany;
final int contactsWithBirthday;
final int totalPhoneNumbers;
final int mobilePhones;
final int workPhones;
final int totalEmails;
final int workEmails;
final int personalEmails;
final Map<String, int> topCompanies;
final Map<int, int> birthdayMonthDistribution;
ContactStatistics({
required this.totalContacts,
required this.contactsWithPhone,
required this.contactsWithEmail,
required this.contactsWithCompany,
required this.contactsWithBirthday,
required this.totalPhoneNumbers,
required this.mobilePhones,
required this.workPhones,
required this.totalEmails,
required this.workEmails,
required this.personalEmails,
required this.topCompanies,
required this.birthdayMonthDistribution,
});
}
// 联系人增长数据
class ContactGrowthData {
final DateTime month;
final int newContacts;
final int totalContacts;
ContactGrowthData({
required this.month,
required this.newContacts,
required this.totalContacts,
});
}
// 联系人活跃度
class ContactActivity {
final String contactId;
final String contactName;
int totalCalls;
int incomingCalls;
int outgoingCalls;
int missedCalls;
Duration totalDuration;
DateTime lastContact;
ContactActivity({
required this.contactId,
required this.contactName,
required this.totalCalls,
required this.incomingCalls,
required this.outgoingCalls,
required this.missedCalls,
required this.totalDuration,
required this.lastContact,
});
}
// 联系人活跃度分析
class ContactActivityAnalysis {
final int totalActivities;
final List<ContactActivity> mostActiveContacts;
final double averageCallsPerContact;
ContactActivityAnalysis({
required this.totalActivities,
required this.mostActiveContacts,
required this.averageCallsPerContact,
});
}
// 联系人分析异常
class ContactAnalyticsException implements Exception {
final String message;
ContactAnalyticsException(this.message);
@override
String toString() => message;
}测试与调试
1. 联系人服务测试
dart
// test/contact_service_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:geo_tracker/services/contact_service.dart';
void main() {
group('ContactService Tests', () {
late ContactService contactService;
setUp(() {
contactService = ContactService();
});
test('should initialize successfully with granted permissions', () async {
// 模拟权限授予
// 这里需要模拟FlutterContacts.requestPermission返回true
await expectLater(contactService.initialize(), completes);
});
test('should throw exception when permissions are denied', () async {
// 模拟权限拒绝
// 这里需要模拟FlutterContacts.requestPermission返回false
await expectLater(
contactService.initialize(),
throwsA(isA<ContactException>()),
);
});
test('should search contacts correctly', () async {
// 模拟联系人数据
final mockContacts = [
Contact(
id: '1',
firstName: 'John',
lastName: 'Doe',
displayName: 'John Doe',
phones: [PhoneNumber(number: '1234567890', label: 'mobile')],
emails: [],
),
Contact(
id: '2',
firstName: 'Jane',
lastName: 'Smith',
displayName: 'Jane Smith',
phones: [PhoneNumber(number: '0987654321', label: 'mobile')],
emails: [],
),
];
// 设置模拟数据
// contactService._contacts = mockContacts;
// 测试搜索
final results = contactService.searchContacts('John');
expect(results.length, equals(1));
expect(results.first.displayName, equals('John Doe'));
});
});
}2. 联系人备份测试
dart
// test/contact_backup_service_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:geo_tracker/services/contact_backup_service.dart';
import 'package:geo_tracker/models/contact.dart';
void main() {
group('ContactBackupService Tests', () {
late ContactBackupService backupService;
late List<Contact> testContacts;
setUp(() {
backupService = ContactBackupService();
testContacts = [
Contact(
id: '1',
firstName: 'John',
lastName: 'Doe',
displayName: 'John Doe',
phones: [PhoneNumber(number: '1234567890', label: 'mobile')],
emails: [Email(address: 'john@example.com', label: 'home')],
),
];
});
test('should backup contacts to JSON successfully', () async {
final file = await backupService.backupContactsToJson(testContacts);
expect(file, isNotNull);
expect(file.path.endsWith('.json'), isTrue);
});
test('should backup contacts to CSV successfully', () async {
final file = await backupService.backupContactsToCsv(testContacts);
expect(file, isNotNull);
expect(file.path.endsWith('.csv'), isTrue);
});
test('should backup contacts to VCard successfully', () async {
final file = await backupService.backupContactsToVCard(testContacts);
expect(file, isNotNull);
expect(file.path.endsWith('.vcf'), isTrue);
});
});
}最佳实践与注意事项
1. 权限管理
- 渐进式权限请求:先请求基本权限,再请求可选权限
- 权限说明:清晰地向用户解释为什么需要联系人权限
- 优雅降级:在权限被拒绝时提供替代功能
2. 性能优化
- 分页加载:对于大量联系人,使用分页加载
- 缓存策略:合理缓存联系人数据,减少重复查询
- 异步操作:所有联系人操作都应该是异步的
3. 数据安全
- 数据加密:对敏感联系人数据进行加密存储
- 最小权限原则:只请求必要的权限
- 数据清理:及时清理不需要的联系人数据
4. 用户体验
- 搜索优化:提供快速、准确的联系人搜索
- 批量操作:支持批量选择和操作联系人
- 状态反馈:提供清晰的操作状态反馈
5. 平台差异
- API 差异:处理 Android 和 iOS 平台 API 的差异
- UI 适配:适配不同平台的 UI 风格
- 功能限制:处理平台功能限制
总结
通过本文的详细介绍,我们成功实现了一个功能完整的联系人管理应用 ContactHub。这个项目涵盖了:
- 联系人管理基础架构:设计了完整的联系人数据管理架构
- 联系人分组功能:实现了联系人的分组管理和统计
- 备份恢复功能:提供了多种格式的联系人备份和恢复
- 用户界面设计:创建了直观的联系人列表和详情界面
- 高级功能:实现了智能推荐和数据分析功能
- 测试与调试:提供了完整的测试方案
联系人管理是移动应用开发中的重要功能,通过 Flutter 的桥接能力,我们可以轻松实现跨平台的联系人管理功能。在实际项目中,还可以根据具体需求进一步扩展功能,比如:
- 集成更多社交平台联系人
- 添加联系人同步功能
- 实现联系人去重和合并
- 添加联系人标签和自定义字段
- 集成 AI 驱动的联系人分析
希望本文能够帮助开发者更好地理解和实现 Flutter 中的联系人管理功能。

