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 | | --- | --- | | Screenshot 2025-02-19 at 2 59 58 PM | Screenshot 2025-02-19 at 2 58 54 PM | | Screenshot 2025-02-19 at 2 59 41 PM | Screenshot 2025-02-19 at 2 58 45 PM | 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); }); }