diff --git a/lib/widgets/default_error_widget.dart b/lib/widgets/default_error_widget.dart index 714ce89..95c748a 100644 --- a/lib/widgets/default_error_widget.dart +++ b/lib/widgets/default_error_widget.dart @@ -1,10 +1,12 @@ import 'package:flutter/material.dart'; class DefaultErrorWidget extends StatelessWidget { - const DefaultErrorWidget({required this.message, this.onRetry, super.key}); + const DefaultErrorWidget({this.title, this.message, this.onRetry, this.iconOverride, super.key}); - final String message; + final String? title; + final String? message; final void Function()? onRetry; + final IconData? iconOverride; @override Widget build(BuildContext context) { @@ -16,13 +18,19 @@ class DefaultErrorWidget extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, children: [ - Text("Something went wrong: ", style: Theme + Icon(iconOverride ?? Icons.warning, size: 32,), + const SizedBox(height: 16,), + Text(title ?? "Something went wrong: ", + textAlign: TextAlign.center, + style: Theme .of(context) .textTheme .titleMedium,), - Padding( + if (message != null) Padding( padding: const EdgeInsets.all(16), - child: Text(message), + child: Text(message ?? "", + textAlign: TextAlign.center, + ), ), if (onRetry != null) TextButton.icon( onPressed: onRetry, diff --git a/lib/widgets/friends_list.dart b/lib/widgets/friends_list.dart index 9a78fbb..cb5a3a2 100644 --- a/lib/widgets/friends_list.dart +++ b/lib/widgets/friends_list.dart @@ -9,8 +9,18 @@ import 'package:contacts_plus_plus/widgets/default_error_widget.dart'; import 'package:contacts_plus_plus/widgets/expanding_input_fab.dart'; import 'package:contacts_plus_plus/widgets/friend_list_tile.dart'; import 'package:contacts_plus_plus/widgets/settings_page.dart'; +import 'package:contacts_plus_plus/widgets/user_search.dart'; import 'package:flutter/material.dart'; + +class MenuItemDefinition { + final String name; + final IconData icon; + final void Function() onTap; + + const MenuItemDefinition({required this.name, required this.icon, required this.onTap}); +} + class FriendsList extends StatefulWidget { const FriendsList({super.key}); @@ -85,11 +95,33 @@ class _FriendsListState extends State { appBar: AppBar( title: const Text("Contacts++"), actions: [ - IconButton( - onPressed: () { - Navigator.of(context).push(MaterialPageRoute(builder: (context) => const SettingsPage())); - }, - icon: const Icon(Icons.settings), + Padding( + padding: const EdgeInsets.only(right: 4), + child: PopupMenuButton( + icon: const Icon(Icons.more_vert), + onSelected: (MenuItemDefinition itemDef) { + itemDef.onTap(); + }, + itemBuilder: (BuildContext context) => [ + MenuItemDefinition(name: "Settings", icon: Icons.settings, onTap: () { + Navigator.of(context).push(MaterialPageRoute(builder: (context) => const SettingsPage())); + }), + MenuItemDefinition(name: "Find Users", icon: Icons.person_add, onTap: () { + Navigator.of(context).push(MaterialPageRoute(builder: (context) => const UserSearch())); + }) + ].map((item) => + PopupMenuItem( + value: item, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(item.name), + Icon(item.icon), + ], + ), + ), + ).toList(), + ), ) ], ), @@ -134,7 +166,8 @@ class _FriendsListState extends State { }, ); } else if (snapshot.hasError) { - FlutterError.reportError(FlutterErrorDetails(exception: snapshot.error!, stack: snapshot.stackTrace)); + FlutterError.reportError( + FlutterErrorDetails(exception: snapshot.error!, stack: snapshot.stackTrace)); return DefaultErrorWidget( message: "${snapshot.error}", onRetry: () { diff --git a/lib/widgets/user_search.dart b/lib/widgets/user_search.dart new file mode 100644 index 0000000..058b38f --- /dev/null +++ b/lib/widgets/user_search.dart @@ -0,0 +1,132 @@ + +import 'dart:async'; + +import 'package:contacts_plus_plus/apis/user_api.dart'; +import 'package:contacts_plus_plus/clients/api_client.dart'; +import 'package:contacts_plus_plus/models/user.dart'; +import 'package:contacts_plus_plus/widgets/default_error_widget.dart'; +import 'package:contacts_plus_plus/widgets/user_list_tile.dart'; +import 'package:flutter/material.dart'; + +class SearchError { + final String message; + final IconData icon; + + const SearchError({required this.message, required this.icon}); +} + +class UserSearch extends StatefulWidget { + const UserSearch({super.key}); + + @override + State createState() => _UserSearchState(); +} + +class _UserSearchState extends State { + final TextEditingController _searchInputController = TextEditingController(); + Timer? _searchDebouncer; + late Future>? _usersFuture = _emptySearch; + bool _isLoading = false; + + Future> get _emptySearch => Future(() => throw const SearchError( + message: "Start typing to search for users", icon: Icons.search) + ); + + void _querySearch(BuildContext context, String needle) { + if (needle.isEmpty) { + _usersFuture = _emptySearch; + return; + } + _usersFuture = UserApi.searchUsers(ClientHolder + .of(context).apiClient, needle: needle).then((value) { + final res = value.toList(); + if (res.isEmpty) throw SearchError(message: "No user found with username '$needle'", icon: Icons.search_off); + res.sort( + (a, b) => a.username.length.compareTo(b.username.length) + ); + return res; + }).whenComplete(() => setState(() => _isLoading = false)); + } + + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text("Find Users"), + ), + body: Column( + children: [ + if(_isLoading) const LinearProgressIndicator(), + Expanded( + child: FutureBuilder( + future: _usersFuture, + builder: (context, snapshot) { + if (snapshot.hasData) { + final users = (snapshot.data as List); + return ListView.builder( + itemCount: users.length, + itemBuilder: (context, index) { + return UserListTile(user: users[index]); + }, + ); + } else if (snapshot.hasError) { + FlutterError.reportError( + FlutterErrorDetails(exception: snapshot.error!, stack: snapshot.stackTrace)); + final err = snapshot.error; + if (err is SearchError) { + return DefaultErrorWidget( + title: err.message, + iconOverride: err.icon, + ); + } else { + return DefaultErrorWidget(title: "${snapshot.error}",); + } + } else { + return const SizedBox.shrink(); + } + } + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: TextField( + decoration: InputDecoration( + isDense: true, + hintText: "Search for users...", + contentPadding: const EdgeInsets.all(16), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(24) + ) + ), + autocorrect: false, + controller: _searchInputController, + onChanged: (String value) { + _searchDebouncer?.cancel(); + if (value.isEmpty) { + setState(() { + _isLoading = false; + _querySearch(context, value); + }); + return; + } + setState(() { + _isLoading = true; + }); + _searchDebouncer = Timer(const Duration(milliseconds: 300), () { + setState(() { + _querySearch(context, value); + }); + }); + }, + ), + ), + ], + ), + ); + } +} \ No newline at end of file