feat: WIP contact-modal

This commit is contained in:
Florian Griffon 2024-12-11 21:54:20 +01:00
parent 3f6ea2e332
commit a64b32d114
7 changed files with 409 additions and 23 deletions

View File

@ -1,6 +1,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.READ_CONTACTS"/> <uses-permission android:name="android.permission.READ_CONTACTS"/>
<uses-permission android:name="android.permission.WRITE_CONTACTS"/> <uses-permission android:name="android.permission.WRITE_CONTACTS"/>
<uses-permission android:name="android.permission.CALL_PHONE" />
<uses-permission android:name="android.permission.SEND_SMS" />
<uses-feature android:name="android.hardware.camera" android:required="false" /> <uses-feature android:name="android.hardware.camera" android:required="false" />
<!-- The INTERNET permission is required for development. Specifically, <!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application the Flutter tool needs it to communicate with the running application

View File

@ -1,6 +1,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.READ_CONTACTS"/> <uses-permission android:name="android.permission.READ_CONTACTS"/>
<uses-permission android:name="android.permission.WRITE_CONTACTS"/> <uses-permission android:name="android.permission.WRITE_CONTACTS"/>
<uses-permission android:name="android.permission.CALL_PHONE" />
<uses-permission android:name="android.permission.SEND_SMS" />
<application <application
android:label="com.example.dialer" android:label="com.example.dialer"
android:name="${applicationName}" android:name="${applicationName}"

View File

@ -1,6 +1,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.READ_CONTACTS"/> <uses-permission android:name="android.permission.READ_CONTACTS"/>
<uses-permission android:name="android.permission.WRITE_CONTACTS"/> <uses-permission android:name="android.permission.WRITE_CONTACTS"/>
<uses-permission android:name="android.permission.CALL_PHONE" />
<uses-permission android:name="android.permission.SEND_SMS" />
<uses-permission android:name="android.permission.CAMERA"/> <uses-permission android:name="android.permission.CAMERA"/>
<uses-feature android:name="android.hardware.camera" android:required="false" /> <uses-feature android:name="android.hardware.camera" android:required="false" />
<!-- The INTERNET permission is required for development. Specifically, <!-- The INTERNET permission is required for development. Specifically,

View File

@ -4,7 +4,7 @@ import 'package:flutter_contacts/flutter_contacts.dart';
class ContactService { class ContactService {
Future<List<Contact>> fetchContacts() async { Future<List<Contact>> fetchContacts() async {
if (await FlutterContacts.requestPermission()) { if (await FlutterContacts.requestPermission()) {
return await FlutterContacts.getContacts(withProperties: true, withThumbnail: true, withAccounts: true, withGroups: true); return await FlutterContacts.getContacts(withProperties: true, withThumbnail: true, withAccounts: true, withGroups: true, withPhoto: true);
} }
return []; return [];
} }

View File

@ -4,13 +4,15 @@ import 'package:flutter_contacts/flutter_contacts.dart';
import '../../../widgets/color_darkener.dart'; import '../../../widgets/color_darkener.dart';
import '../contact_state.dart'; import '../contact_state.dart';
import 'add_contact_button.dart'; import 'add_contact_button.dart';
import 'contact_modal.dart';
import 'share_own_qr.dart'; import 'share_own_qr.dart';
class AlphabetScrollPage extends StatefulWidget { class AlphabetScrollPage extends StatefulWidget {
final List<Contact> contacts; final List<Contact> contacts;
final double scrollOffset; final double scrollOffset;
const AlphabetScrollPage({super.key, required this.contacts, required this.scrollOffset}); const AlphabetScrollPage(
{super.key, required this.contacts, required this.scrollOffset});
@override @override
_AlphabetScrollPageState createState() => _AlphabetScrollPageState(); _AlphabetScrollPageState createState() => _AlphabetScrollPageState();
@ -18,11 +20,14 @@ class AlphabetScrollPage extends StatefulWidget {
class _AlphabetScrollPageState extends State<AlphabetScrollPage> { class _AlphabetScrollPageState extends State<AlphabetScrollPage> {
late ScrollController _scrollController; late ScrollController _scrollController;
List<Contact> _contacts = []; // Local copy of contacts for updating
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_scrollController = ScrollController(initialScrollOffset: widget.scrollOffset); _contacts = widget.contacts; // Initialize with the provided contacts
_scrollController =
ScrollController(initialScrollOffset: widget.scrollOffset);
_scrollController.addListener(_onScroll); _scrollController.addListener(_onScroll);
} }
@ -31,11 +36,149 @@ class _AlphabetScrollPageState extends State<AlphabetScrollPage> {
contactState.setScrollOffset(_scrollController.offset); contactState.setScrollOffset(_scrollController.offset);
} }
Future<void> _refreshContacts() async {
if (await FlutterContacts.requestPermission()) {
final updatedContacts = await FlutterContacts.getContacts(
withProperties: true, withThumbnail: true);
setState(() {
_contacts = updatedContacts;
});
}
}
// void _toggleFavorite(Contact contact) async {
// print(contact.id);
// if (await FlutterContacts.requestPermission()) {
// try {
// // Fetch all contacts (this can be slow if there are many contacts)
// List<Contact> allContacts = await FlutterContacts.getContacts(
// withProperties: true,
// withThumbnail: true,
// withAccounts: true,
// withPhoto: true
// );
// // Find the specific contact by matching contact.id
// // Use `orElse` to return a nullable Contact? or throw an exception if not found
// Contact? contactToUpdate = allContacts.firstWhere(
// (c) => c.id == contact.id,
// orElse: () => throw Exception(
// "Contact not found"), // Throw an exception if not found
// );
// if (contactToUpdate != null) {
// print("Contact fetched: ${contactToUpdate.displayName}");
// print("Current isStarred status: ${contactToUpdate.isStarred}");
// // Toggle the favorite status
// contactToUpdate.isStarred = !contactToUpdate.isStarred;
// print("Updated isStarred status: ${contactToUpdate.isStarred}");
// // Update the contact
// await FlutterContacts.updateContact(contactToUpdate);
// print("Contact updated successfully");
// // Refresh the UI by updating the state
// setState(() {
// contact.isStarred = contactToUpdate.isStarred;
// });
// }
// } catch (e) {
// print("Error updating favorite status: $e");
// }
// }
// }
// void _toggleFavorite(Contact contact) async {
// print(contact.id);
// try {
// // Fetch the contact with details
// final contactWithDetails = await FlutterContacts.getContact(contact.id,
// withProperties: true, withThumbnail: true, withAccounts: true);
// if (contactWithDetails != null) {
// print("Contact fetched: ${contactWithDetails.displayName}");
// print("Current isStarred status: ${contactWithDetails.isStarred}");
// // Toggle the favorite status
// contactWithDetails.isStarred = !contactWithDetails.isStarred;
// print("Updated isStarred status: ${contactWithDetails.isStarred}");
// // Update the contact
// await FlutterContacts.updateContact(contactWithDetails);
// print("Contact updated successfully");
// // Reset the contact state (optional)
// setState(() {
// contact.isStarred = contactWithDetails.isStarred;
// });
// // Re-fetch the contact to ensure UI reflects changes
// final updatedContact = await FlutterContacts.getContact(contact.id,
// withProperties: true, withThumbnail: true, withAccounts: true);
// if (updatedContact != null) {
// print("Re-fetched updated contact: ${updatedContact.displayName}");
// print("Re-fetched isStarred status: ${updatedContact.isStarred}");
// } else {
// print("Re-fetching contact failed.");
// }
// } else {
// print("Contact details are not available");
// }
// } catch (e) {
// print("Error updating favorite status: $e");
// }
// }
void _toggleFavorite(Contact contact) async {
print(contact.id);
try {
if (contact != null) {
print("Contact fetched: ${contact.displayName}");
print("Current isStarred status: ${contact.isStarred}");
// Toggle the favorite status
contact.isStarred = !contact.isStarred;
print("Updated isStarred status: ${contact.isStarred}");
// Update the contact
await FlutterContacts.updateContact(contact);
print("Contact updated successfully");
// Reset the contact state (optional)
setState(() {
contact.isStarred = contact.isStarred;
});
// Re-fetch the contact to ensure UI reflects changes
final updatedContact = await FlutterContacts.getContact(contact.id,
withProperties: true, withThumbnail: true, withAccounts: true);
if (updatedContact != null) {
print("Re-fetched updated contact: ${updatedContact.displayName}");
print("Re-fetched isStarred status: ${updatedContact.isStarred}");
} else {
print("Re-fetching contact failed.");
}
} else {
print("Contact details are not available");
}
} catch (e) {
print("Error updating favorite status: $e");
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
Map<String, List<Contact>> alphabetizedContacts = {}; Map<String, List<Contact>> alphabetizedContacts = {};
for (var contact in widget.contacts) { for (var contact in _contacts) {
String firstLetter = contact.displayName.isNotEmpty ? contact.displayName[0].toUpperCase() : '#'; String firstLetter = contact.displayName.isNotEmpty
? contact.displayName[0].toUpperCase()
: '#';
if (!alphabetizedContacts.containsKey(firstLetter)) { if (!alphabetizedContacts.containsKey(firstLetter)) {
alphabetizedContacts[firstLetter] = []; alphabetizedContacts[firstLetter] = [];
} }
@ -47,15 +190,19 @@ class _AlphabetScrollPageState extends State<AlphabetScrollPage> {
return Scaffold( return Scaffold(
backgroundColor: Colors.black, backgroundColor: Colors.black,
body: Column( body: Column(
children: [ // Top buttons row children: [
// Top buttons row
Container( Container(
color: Colors.black, color: Colors.black,
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), padding:
const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
AddContactButton(), AddContactButton(),
QRCodeButton(contacts: widget.contacts, selfContact: ContactState.of(context).selfContact), QRCodeButton(
contacts: _contacts,
selfContact: ContactState.of(context).selfContact),
], ],
), ),
), ),
@ -72,7 +219,8 @@ class _AlphabetScrollPageState extends State<AlphabetScrollPage> {
children: [ children: [
// Alphabet Letter Header // Alphabet Letter Header
Padding( Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0), padding: const EdgeInsets.symmetric(
vertical: 8.0, horizontal: 16.0),
child: Text( child: Text(
letter, letter,
style: TextStyle( style: TextStyle(
@ -84,24 +232,75 @@ class _AlphabetScrollPageState extends State<AlphabetScrollPage> {
), ),
// Contact Entries // Contact Entries
...contacts.map((contact) { ...contacts.map((contact) {
String phoneNumber = contact.phones.isNotEmpty ? contact.phones.first.number : 'No phone number'; String phoneNumber = contact.phones.isNotEmpty
Color avatarColor = generateColorFromName(contact.displayName); ? contact.phones.first.number
: 'No phone number';
Color avatarColor =
generateColorFromName(contact.displayName);
return ListTile( return ListTile(
leading: (contact.thumbnail != null && contact.thumbnail!.isNotEmpty) leading: (contact.thumbnail != null &&
contact.thumbnail!.isNotEmpty)
? CircleAvatar( ? CircleAvatar(
backgroundImage: MemoryImage(contact.thumbnail!), backgroundImage:
) MemoryImage(contact.thumbnail!),
)
: CircleAvatar( : CircleAvatar(
backgroundColor: avatarColor, backgroundColor: avatarColor,
child: Text( child: Text(
contact.displayName.isNotEmpty ? contact.displayName[0].toUpperCase() : '?', contact.displayName.isNotEmpty
style: TextStyle(color: darken(avatarColor, 0.4)), ? contact.displayName[0].toUpperCase()
), : '?',
), style: TextStyle(
title: Text(contact.displayName, style: TextStyle(color: Colors.white)), color: darken(avatarColor, 0.4)),
subtitle: Text(phoneNumber, style: TextStyle(color: Colors.white70)), ),
),
title: Text(contact.displayName,
style: TextStyle(color: Colors.white)),
subtitle: Text(phoneNumber,
style: TextStyle(color: Colors.white70)),
onTap: () { onTap: () {
// Handle contact tap showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) {
return ContactModal(
contact: contact,
onEdit: () async {
// Trigger edit logic and refresh contacts
if (await FlutterContacts
.requestPermission()) {
final updatedContact =
await FlutterContacts.openExternalEdit(
contact.id);
if (updatedContact != null) {
await _refreshContacts();
Navigator.of(context).pop();
ScaffoldMessenger.of(context)
.showSnackBar(
SnackBar(
content: Text(
'${contact.displayName} updated successfully!'),
),
);
} else {
ScaffoldMessenger.of(context)
.showSnackBar(
SnackBar(
content:
Text('Edit canceled or failed.'),
),
);
}
}
},
onToggleFavorite: () {
_toggleFavorite(contact);
},
isFavorite: contact.isStarred,
);
},
);
}, },
); );
}), }),

View File

@ -0,0 +1,180 @@
import 'package:flutter/material.dart';
import 'package:flutter_contacts/flutter_contacts.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:dialer/widgets/username_color_generator.dart';
class ContactModal extends StatelessWidget {
final Contact contact;
final Function onEdit;
final Function onToggleFavorite;
final bool isFavorite;
const ContactModal({
super.key,
required this.contact,
required this.onEdit,
required this.onToggleFavorite,
required this.isFavorite,
});
void _launchPhoneDialer(String phoneNumber) async {
final uri = Uri(scheme: 'tel', path: phoneNumber);
if (await canLaunchUrl(uri)) {
await launchUrl(uri);
} else {
debugPrint('Could not launch $phoneNumber');
}
}
void _launchSms(String phoneNumber) async {
final uri = Uri(scheme: 'sms', path: phoneNumber);
if (await canLaunchUrl(uri)) {
await launchUrl(uri);
} else {
debugPrint('Could not launch SMS to $phoneNumber');
}
}
void _launchEmail(String email) async {
final uri = Uri(scheme: 'mailto', path: email);
if (await canLaunchUrl(uri)) {
await launchUrl(uri);
} else {
debugPrint('Could not launch email to $email');
}
}
@override
Widget build(BuildContext context) {
String phoneNumber = contact.phones.isNotEmpty
? contact.phones.first.number
: 'No phone number';
String email =
contact.emails.isNotEmpty ? contact.emails.first.address : 'No email';
return GestureDetector(
onTap: () => Navigator.of(context).pop(),
child: Container(
color: Colors.black.withOpacity(0.5),
child: GestureDetector(
onTap: () {},
child: FractionallySizedBox(
heightFactor: 0.7,
child: Container(
decoration: BoxDecoration(
color: Colors.grey[900],
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Modal Handle
Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Container(
width: 50,
height: 5,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(5),
),
),
),
// Contact Profile
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
CircleAvatar(
radius: 50,
backgroundImage: (contact.thumbnail != null &&
contact.thumbnail!.isNotEmpty)
? MemoryImage(contact.thumbnail!)
: null,
backgroundColor:
generateColorFromName(contact.displayName),
child: (contact.thumbnail == null ||
contact.thumbnail!.isEmpty)
? Text(
contact.displayName.isNotEmpty
? contact.displayName[0].toUpperCase()
: '?',
style: TextStyle(
fontSize: 40, color: Colors.white),
)
: null,
),
SizedBox(height: 10),
Text(
contact.displayName,
style: TextStyle(
fontSize: 24, fontWeight: FontWeight.bold),
),
],
),
),
// Contact Actions
Divider(),
ListTile(
leading: Icon(Icons.phone, color: Colors.green),
title: Text(phoneNumber),
onTap: () {
if (contact.phones.isNotEmpty) {
_launchPhoneDialer(phoneNumber);
}
},
),
ListTile(
leading: Icon(Icons.message, color: Colors.blue),
title: Text(phoneNumber),
onTap: () {
if (contact.phones.isNotEmpty) {
_launchSms(phoneNumber);
}
},
),
ListTile(
leading: Icon(Icons.email, color: Colors.orange),
title: Text(email),
onTap: () {
if (contact.emails.isNotEmpty) {
_launchEmail(email);
}
},
),
Divider(),
// Favorite and Edit Buttons
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ElevatedButton.icon(
onPressed: () {
Navigator.of(context).pop();
onToggleFavorite();
},
icon: Icon(contact.isStarred
? Icons.star
: Icons.star_border),
label: Text(
contact.isStarred ? 'Unfavorite' : 'Favorite'),
),
ElevatedButton.icon(
onPressed: () => onEdit(),
icon: Icon(Icons.edit),
label: Text('Edit Contact'),
),
],
),
),
SizedBox(height: 16),
],
),
),
),
),
),
);
}
}

View File

@ -34,6 +34,7 @@ dependencies:
# The following adds the Cupertino Icons font to your application. # The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons. # Use with the CupertinoIcons class for iOS style icons.
url_launcher: ^6.1.9 # To manage system dialer. Call, message...
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8
flutter_contacts: ^1.1.9+2 flutter_contacts: ^1.1.9+2
permission_handler: ^11.3.1 # For handling permissions permission_handler: ^11.3.1 # For handling permissions