From c787dd01a7e99a70d1cd8be57d546fc92aff0470 Mon Sep 17 00:00:00 2001
From: Mitchell Goodwin <58190796+MitchellGoodwin@users.noreply.github.com>
Date: Mon, 24 Feb 2025 10:06:50 -0800
Subject: [PATCH] [Rolodex] Update fidelity of Contact list screens (#2588)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This PR:
- Updates the fidelity of the home screen and contact list screens to near full fidelity
- Renames "Contact Lists" to "Contact Groups". We have multiple collections of contacts, so having lists of lists felt confusing
- Adds more functionality to the data models
Comparison of the two screens against native:
| Native | Flutter |
| --- | --- |
|
|
|
|
|
|
Notably the container widget on the first screen is a placeholder, until a Cupertino collapsable widget is made.
---
rolodex/lib/data/contact.dart | 38 +++++-
rolodex/lib/data/contact_group.dart | 118 ++++++++++++++++++
rolodex/lib/data/contact_list.dart | 44 -------
rolodex/lib/main.dart | 14 ++-
.../{lists.dart => contact_groups.dart} | 53 ++++++--
rolodex/lib/screens/contacts.dart | 103 +++++++++++++--
rolodex/test/widget_test.dart | 5 +-
7 files changed, 306 insertions(+), 69 deletions(-)
create mode 100644 rolodex/lib/data/contact_group.dart
delete mode 100644 rolodex/lib/data/contact_list.dart
rename rolodex/lib/screens/{lists.dart => contact_groups.dart} (55%)
diff --git a/rolodex/lib/data/contact.dart b/rolodex/lib/data/contact.dart
index 0fb22ec47..2348f7fbd 100644
--- a/rolodex/lib/data/contact.dart
+++ b/rolodex/lib/data/contact.dart
@@ -3,7 +3,43 @@
// found in the LICENSE file.
class Contact {
- Contact({required this.id});
+ Contact({
+ required this.id,
+ required this.firstName,
+ this.middleName,
+ required this.lastName,
+ this.suffix,
+ });
final int id;
+ final String firstName;
+ final String lastName;
+ final String? middleName;
+ final String? suffix;
}
+
+final johnAppleseed = Contact(id: 0, firstName: 'John', lastName: 'Appleseed');
+final kateBell = Contact(id: 1, firstName: 'Kate', lastName: 'Bell');
+final annaHaro = Contact(id: 2, firstName: 'Anna', lastName: 'Haro');
+final danielHiggins = Contact(
+ id: 3,
+ firstName: 'Daniel',
+ lastName: 'Higgins',
+ suffix: 'Jr.',
+);
+final davidTaylor = Contact(id: 4, firstName: 'David', lastName: 'Taylor');
+final hankZakroff = Contact(
+ id: 5,
+ firstName: 'Hank',
+ middleName: 'M.',
+ lastName: 'Zakroff',
+);
+
+final Set allContacts = {
+ johnAppleseed,
+ kateBell,
+ annaHaro,
+ danielHiggins,
+ davidTaylor,
+ hankZakroff,
+};
diff --git a/rolodex/lib/data/contact_group.dart b/rolodex/lib/data/contact_group.dart
new file mode 100644
index 000000000..0671238c1
--- /dev/null
+++ b/rolodex/lib/data/contact_group.dart
@@ -0,0 +1,118 @@
+// Copyright 2018 The Flutter team. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'dart:collection';
+
+import 'package:flutter/cupertino.dart';
+
+import 'contact.dart';
+
+void _sortContacts(List contacts) {
+ contacts.sort((Contact a, Contact b) {
+ final int checkLastName = a.lastName.compareTo(b.lastName);
+ if (checkLastName != 0) {
+ return checkLastName;
+ }
+ final int checkFirstName = a.firstName.compareTo(b.firstName);
+ if (checkFirstName != 0) {
+ return checkFirstName;
+ }
+ if (a.middleName != null && b.middleName != null) {
+ final int checkMiddleName = a.middleName!.compareTo(b.middleName!);
+ if (checkMiddleName != 0) {
+ return checkMiddleName;
+ }
+ } else if (a.middleName != null || b.middleName != null) {
+ return a.middleName != null ? 1 : -1;
+ }
+ // If both contacts have the exact same name, order by first created.
+ return a.id.compareTo(b.id);
+ });
+}
+
+typedef AlphabetizedContactMap = SplayTreeMap>;
+
+class ContactGroup {
+ factory ContactGroup({
+ required int id,
+ required String label,
+ bool permanent = false,
+ String? title,
+ List? contacts,
+ }) {
+ final contactsCopy = contacts ?? [];
+ _sortContacts(contactsCopy);
+ return ContactGroup._internal(
+ id: id,
+ label: label,
+ permanent: permanent,
+ title: title,
+ contacts: contactsCopy,
+ );
+ }
+
+ ContactGroup._internal({
+ required this.id,
+ required this.label,
+ this.permanent = false,
+ String? title,
+ List? contacts,
+ }) : title = title ?? label,
+ _contacts = contacts ?? const [];
+
+ final int id;
+
+ final bool permanent;
+
+ final String label;
+
+ final String title;
+
+ final List _contacts;
+
+ List get contacts => _contacts;
+
+ AlphabetizedContactMap get alphabetizedContacts {
+ final AlphabetizedContactMap contactsMap = AlphabetizedContactMap();
+ for (Contact contact in _contacts) {
+ final String lastInitial = contact.lastName[0].toUpperCase();
+ if (contactsMap.containsKey(lastInitial)) {
+ contactsMap[lastInitial]!.add(contact);
+ } else {
+ contactsMap[lastInitial] = [contact];
+ }
+ }
+ return contactsMap;
+ }
+}
+
+class ContactGroupsModel extends ChangeNotifier {
+ final List _lists = generateSeedData();
+
+ List get lists => _lists;
+
+ ContactGroup findContactList(int id) {
+ return lists[id];
+ }
+}
+
+final allPhone = ContactGroup(
+ id: 0,
+ permanent: true,
+ label: 'All iPhone',
+ title: 'iPhone',
+ contacts: allContacts.toList(),
+);
+
+final friends = ContactGroup(
+ id: 1,
+ label: 'Friends',
+ contacts: [allContacts.elementAt(3)],
+);
+
+final work = ContactGroup(id: 2, label: 'Work');
+
+List generateSeedData() {
+ return [allPhone, friends, work];
+}
diff --git a/rolodex/lib/data/contact_list.dart b/rolodex/lib/data/contact_list.dart
deleted file mode 100644
index 7cbbb9e89..000000000
--- a/rolodex/lib/data/contact_list.dart
+++ /dev/null
@@ -1,44 +0,0 @@
-// Copyright 2018 The Flutter team. All rights reserved.
-// Use of this source code is governed by a BSD-style license that can be
-// found in the LICENSE file.
-
-import 'package:flutter/cupertino.dart';
-
-import 'contact.dart';
-
-class ContactList {
- ContactList({
- required this.id,
- required this.label,
- this.permanent = false,
- String? title,
- }) : title = title ?? label;
-
- final int id;
-
- final bool permanent;
-
- final String label;
-
- final String title;
-
- final List contacts = [];
-}
-
-class ContactListsModel extends ChangeNotifier {
- final List _lists = generateSeedData();
-
- List get lists => _lists;
-
- ContactList findContactList(int id) {
- return lists[id];
- }
-}
-
-List generateSeedData() {
- return [
- ContactList(id: 0, permanent: true, label: 'All iPhone', title: 'iPhone'),
- ContactList(id: 1, label: 'Friends'),
- ContactList(id: 2, label: 'Work'),
- ];
-}
diff --git a/rolodex/lib/main.dart b/rolodex/lib/main.dart
index 7d177a54b..238ef0164 100644
--- a/rolodex/lib/main.dart
+++ b/rolodex/lib/main.dart
@@ -5,9 +5,9 @@
import 'package:flutter/cupertino.dart';
import 'package:provider/provider.dart';
-import 'data/contact_list.dart';
+import 'data/contact_group.dart';
import 'screens/contacts.dart';
-import 'screens/lists.dart';
+import 'screens/contact_groups.dart';
void main() {
runApp(const RolodexApp());
@@ -19,16 +19,22 @@ class RolodexApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
- create: (context) => ContactListsModel(),
+ create: (context) => ContactGroupsModel(),
child: CupertinoApp(
title: 'Rolodex',
+ theme: CupertinoThemeData(
+ barBackgroundColor: CupertinoDynamicColor.withBrightness(
+ color: Color(0xFFF9F9F9),
+ darkColor: Color(0xFF1D1D1D),
+ ),
+ ),
initialRoute: '/contacts',
onGenerateInitialRoutes: (initialRoute) {
return [
CupertinoPageRoute(
title: 'Lists',
builder: (BuildContext context) {
- return ListsPage();
+ return ContactGroupsPage();
},
),
CupertinoPageRoute(
diff --git a/rolodex/lib/screens/lists.dart b/rolodex/lib/screens/contact_groups.dart
similarity index 55%
rename from rolodex/lib/screens/lists.dart
rename to rolodex/lib/screens/contact_groups.dart
index b1ef38ac3..e7d66ddd3 100644
--- a/rolodex/lib/screens/lists.dart
+++ b/rolodex/lib/screens/contact_groups.dart
@@ -4,11 +4,30 @@
import 'package:flutter/cupertino.dart';
import 'package:provider/provider.dart';
-import 'package:rolodex/data/contact_list.dart';
+import 'package:rolodex/data/contact.dart';
+import 'package:rolodex/data/contact_group.dart';
import 'contacts.dart';
-class ListsPage extends StatelessWidget {
- const ListsPage({super.key});
+class ContactGroupsPage extends StatelessWidget {
+ const ContactGroupsPage({super.key});
+
+ Widget _buildTrailing(List contacts, BuildContext context) {
+ final TextStyle style = CupertinoTheme.of(
+ context,
+ ).textTheme.textStyle.copyWith(color: CupertinoColors.systemGrey);
+
+ return Row(
+ spacing: 5,
+ children: [
+ Text(contacts.length.toString(), style: style),
+ Icon(
+ CupertinoIcons.forward,
+ color: CupertinoColors.systemGrey3,
+ size: 18,
+ ),
+ ],
+ );
+ }
@override
Widget build(BuildContext context) {
@@ -17,6 +36,8 @@ class ListsPage extends StatelessWidget {
child: CustomScrollView(
slivers: [
CupertinoSliverNavigationBar(
+ padding: EdgeInsetsDirectional.only(start: 8, end: 16),
+ stretch: true,
leading: CupertinoButton(
padding: EdgeInsets.zero,
onPressed: () {},
@@ -30,19 +51,31 @@ class ListsPage extends StatelessWidget {
),
),
SliverFillRemaining(
- child: Consumer(
+ child: Consumer(
builder: (context, contactLists, child) {
+ const groupIcon = Icon(
+ CupertinoIcons.group,
+ weight: 900,
+ size: 32,
+ );
+
+ const pairIcon = Icon(
+ CupertinoIcons.person_2,
+ weight: 900,
+ size: 24,
+ );
+
return CupertinoListSection.insetGrouped(
header: Text('iPhone'),
children: [
- for (ContactList contactList in contactLists.lists)
+ for (ContactGroup contactList in contactLists.lists)
CupertinoListTile(
- leading: Icon(
- contactList.id == 0
- ? CupertinoIcons.group
- : CupertinoIcons.person_2,
- ),
+ leading: contactList.id == 0 ? groupIcon : pairIcon,
+ leadingSize: 32,
+ leadingToTitle: 9,
+ padding: EdgeInsets.symmetric(horizontal: 13.0),
title: Text(contactList.label),
+ trailing: _buildTrailing(contactList.contacts, context),
onTap:
() => Navigator.of(context).push(
CupertinoPageRoute(
diff --git a/rolodex/lib/screens/contacts.dart b/rolodex/lib/screens/contacts.dart
index 7d5f6d2f0..86ac5ae9b 100644
--- a/rolodex/lib/screens/contacts.dart
+++ b/rolodex/lib/screens/contacts.dart
@@ -4,7 +4,8 @@
import 'package:flutter/cupertino.dart';
import 'package:provider/provider.dart';
-import 'package:rolodex/data/contact_list.dart';
+import 'package:rolodex/data/contact.dart';
+import 'package:rolodex/data/contact_group.dart';
class ContactListsPage extends StatelessWidget {
const ContactListsPage({super.key, required this.listId});
@@ -14,20 +15,42 @@ class ContactListsPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return CupertinoPageScaffold(
- child: Consumer(
- builder: (context, contactLists, child) {
- final ContactList contacts = contactLists.findContactList(listId);
+ child: Consumer(
+ builder: (context, contactGroups, child) {
+ final ContactGroup contactList = contactGroups.findContactList(
+ listId,
+ );
+ final AlphabetizedContactMap contacts =
+ contactList.alphabetizedContacts;
return CustomScrollView(
slivers: [
- CupertinoSliverNavigationBar(
+ CupertinoSliverNavigationBar.search(
+ padding: EdgeInsetsDirectional.only(start: 8, end: 6),
+ transitionBetweenRoutes: false,
automaticallyImplyLeading: true,
- largeTitle: Text(contacts.title),
+ largeTitle: Text(contactList.title),
trailing: CupertinoButton(
+ padding: EdgeInsets.zero,
onPressed: () {},
- child: Icon(CupertinoIcons.add),
+ child: Icon(CupertinoIcons.add, size: 25),
+ ),
+ bottomMode: NavigationBarBottomMode.always,
+ searchField: CupertinoSearchTextField(
+ suffixIcon: Icon(CupertinoIcons.mic_fill),
+ suffixMode: OverlayVisibilityMode.always,
),
),
- SliverFillRemaining(child: Center(child: Text('Contacts page'))),
+ SliverList.list(
+ children: [
+ SizedBox(height: 20),
+ ...contacts.keys.map(
+ (String initial) => ContactListSection(
+ lastInitial: initial,
+ contacts: contacts[initial]!,
+ ),
+ ),
+ ],
+ ),
],
);
},
@@ -35,3 +58,67 @@ class ContactListsPage extends StatelessWidget {
);
}
}
+
+// Section of contacts grouped under the first letter of their last name.
+class ContactListSection extends StatelessWidget {
+ const ContactListSection({
+ super.key,
+ required this.lastInitial,
+ required this.contacts,
+ });
+
+ final String lastInitial;
+
+ final List contacts;
+
+ @override
+ Widget build(BuildContext context) {
+ return Padding(
+ padding: EdgeInsetsDirectional.fromSTEB(20, 0, 20, 0),
+ child: Column(
+ children: [
+ SizedBox(height: 15),
+ Align(
+ alignment: AlignmentDirectional.bottomStart,
+ child: Text(
+ lastInitial,
+ style: TextStyle(
+ color: CupertinoColors.systemGrey,
+ fontSize: 15,
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ),
+ CupertinoListSection(
+ backgroundColor: CupertinoColors.systemBackground,
+ dividerMargin: 0,
+ additionalDividerMargin: 0,
+ topMargin: 4,
+ children:
+ contacts.map((contact) {
+ return CupertinoListTile(
+ padding: EdgeInsetsDirectional.only(start: 0.0, end: 0.0),
+ title: RichText(
+ text: TextSpan(
+ text: "${contact.firstName} ",
+ style: DefaultTextStyle.of(context).style,
+ children: [
+ if (contact.middleName != null)
+ TextSpan(text: "${contact.middleName} "),
+ TextSpan(
+ text: contact.lastName,
+ style: TextStyle(fontWeight: FontWeight.w600),
+ ),
+ if (contact.suffix != null)
+ TextSpan(text: " ${contact.suffix}"),
+ ],
+ ),
+ ),
+ );
+ }).toList(),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/rolodex/test/widget_test.dart b/rolodex/test/widget_test.dart
index 0cfa2b575..67f9e7c96 100644
--- a/rolodex/test/widget_test.dart
+++ b/rolodex/test/widget_test.dart
@@ -1,18 +1,19 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:rolodex/main.dart';
+import 'package:rolodex/screens/contacts.dart';
void main() {
testWidgets('Opens on all contacts page', (WidgetTester tester) async {
await tester.pumpWidget(const RolodexApp());
- expect(find.text('Contacts page'), findsOneWidget);
+ expect(find.byType(ContactListsPage), findsOneWidget);
expect(find.text('Add List'), findsNothing);
await tester.tap(find.text('Lists'));
await tester.pumpAndSettle();
expect(find.text('Add List'), findsOneWidget);
- expect(find.text('Contacts page'), findsNothing);
+ expect(find.byType(ContactListsPage), findsNothing);
});
}