diff --git a/lib/apis/user_api.dart b/lib/apis/user_api.dart new file mode 100644 index 0000000..c64cb15 --- /dev/null +++ b/lib/apis/user_api.dart @@ -0,0 +1,13 @@ +import 'dart:convert'; + +import 'package:contacts_plus/api_client.dart'; +import 'package:contacts_plus/models/user.dart'; + +class UserApi { + static Future> searchUsers(ApiClient client, {required String needle}) async { + final response = await client.get("/users?name=$needle"); + ApiClient.checkResponse(response); + final data = jsonDecode(response.body) as List; + return data.map((e) => User.fromMap(e)); + } +} \ No newline at end of file diff --git a/lib/models/user.dart b/lib/models/user.dart new file mode 100644 index 0000000..a6268d5 --- /dev/null +++ b/lib/models/user.dart @@ -0,0 +1,25 @@ +import 'package:contacts_plus/models/user_profile.dart'; + +class User { + final String id; + final String username; + final DateTime registrationDate; + final UserProfile? userProfile; + + const User({required this.id, required this.username, required this.registrationDate, this.userProfile}); + + factory User.fromMap(Map map) { + UserProfile? profile; + try { + profile = UserProfile.fromMap(map["profile"]); + } catch (e) { + profile = null; + } + return User( + id: map["id"], + username: map["username"], + registrationDate: DateTime.parse(map["registrationDate"]), + userProfile: profile, + ); + } +} \ No newline at end of file diff --git a/lib/models/user_profile.dart b/lib/models/user_profile.dart index d307dcd..273b4e0 100644 --- a/lib/models/user_profile.dart +++ b/lib/models/user_profile.dart @@ -13,6 +13,7 @@ class UserProfile { final fullUri = iconUrl.replaceFirst("neosdb:///", Config.neosCdnUrl); final lastPeriodIndex = fullUri.lastIndexOf("."); if (lastPeriodIndex != -1 && fullUri.length - lastPeriodIndex < 8) { + // I feel like 8 is a good maximum for file extension length? Can neosdb Uris even come without file extensions? return Uri.parse(fullUri.substring(0, lastPeriodIndex)); } return Uri.parse(fullUri); diff --git a/lib/widgets/expanding_input_fab.dart b/lib/widgets/expanding_input_fab.dart new file mode 100644 index 0000000..efd75f4 --- /dev/null +++ b/lib/widgets/expanding_input_fab.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; + +class ExpandingInputFab extends StatefulWidget { + const ExpandingInputFab({this.onExpansionChanged, this.onInputChanged, super.key}); + + final Function(bool expanded)? onExpansionChanged; + final Function(String text)? onInputChanged; + + @override + State createState() => _ExpandingInputFabState(); +} + +class _ExpandingInputFabState extends State { + final TextEditingController _inputController = TextEditingController(); + final FocusNode _inputFocusNode = FocusNode(); + bool _isExtended = false; + + @override + Widget build(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + return Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + AnimatedSize( + alignment: Alignment.bottomRight, + duration: const Duration(milliseconds: 300), + reverseDuration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: Theme.of(context).colorScheme.secondaryContainer, + ), + padding: const EdgeInsets.all(6), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: _isExtended ? screenWidth * 0.75 : 0.0, + child: _isExtended ? TextField( + focusNode: _inputFocusNode, + onChanged: widget.onInputChanged, + controller: _inputController, + decoration: const InputDecoration( + border: OutlineInputBorder( + borderSide: BorderSide.none, + ), + isDense: true + ), + ) : null, + ), + Padding( + padding: const EdgeInsets.all(4.0), + child: IconButton( + onPressed: () { + setState(() { + _isExtended = !_isExtended; + }); + if (_isExtended) _inputFocusNode.requestFocus(); + _inputController.clear(); + widget.onInputChanged?.call(""); + widget.onExpansionChanged?.call(_isExtended); + }, + splashRadius: 16, + icon: _isExtended ? const Icon(Icons.close) : const Icon(Icons.search), + ), + ) + ], + ), + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/friend_list_tile.dart b/lib/widgets/friend_list_tile.dart new file mode 100644 index 0000000..6cbf4d9 --- /dev/null +++ b/lib/widgets/friend_list_tile.dart @@ -0,0 +1,37 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:contacts_plus/models/friend.dart'; +import 'package:contacts_plus/widgets/messages.dart'; +import 'package:flutter/material.dart'; + +class FriendListTile extends StatelessWidget { + const FriendListTile({required this.friend, super.key}); + + final Friend friend; + + @override + Widget build(BuildContext context) { + return ListTile( + leading: CachedNetworkImage( + imageBuilder: (context, imageProvider) { + return CircleAvatar( + foregroundImage: imageProvider, + ); + }, + imageUrl: friend.userProfile.httpIconUri.toString(), + placeholder: (context, url) { + return const CircleAvatar(backgroundColor: Colors.white54,); + }, + errorWidget: (context, error, what) => const CircleAvatar( + backgroundColor: Colors.transparent, + child: Icon(Icons.person), + ), + ), + title: Text(friend.username), + subtitle: Text(friend.userStatus.onlineStatus.name), + onTap: () { + Navigator.of(context).push(MaterialPageRoute(builder: (context) => Messages(friend: friend))); + }, + ); + } + +} \ No newline at end of file diff --git a/lib/widgets/home_screen.dart b/lib/widgets/home_screen.dart index f8f6a43..ecef68c 100644 --- a/lib/widgets/home_screen.dart +++ b/lib/widgets/home_screen.dart @@ -1,9 +1,13 @@ -import 'package:cached_network_image/cached_network_image.dart'; +import 'dart:async'; + import 'package:contacts_plus/apis/friend_api.dart'; -import 'package:contacts_plus/aux.dart'; +import 'package:contacts_plus/apis/user_api.dart'; import 'package:contacts_plus/main.dart'; import 'package:contacts_plus/models/friend.dart'; -import 'package:contacts_plus/widgets/messages.dart'; +import 'package:contacts_plus/models/user.dart'; +import 'package:contacts_plus/widgets/expanding_input_fab.dart'; +import 'package:contacts_plus/widgets/friend_list_tile.dart'; +import 'package:contacts_plus/widgets/user_list_tile.dart'; import 'package:flutter/material.dart'; class HomeScreen extends StatefulWidget { @@ -14,8 +18,16 @@ class HomeScreen extends StatefulWidget { } class _HomeScreenState extends State { - Future>? _friendsFuture; + Future? _listFuture; + Future? _friendFuture; ClientHolder? _clientHolder; + Timer? _debouncer; + + @override + void dispose() { + _debouncer?.cancel(); + super.dispose(); + } @override void didChangeDependencies() { @@ -28,7 +40,7 @@ class _HomeScreenState extends State { } void _refreshFriendsList() { - _friendsFuture = FriendApi.getFriendsList(_clientHolder!.client).then((Iterable value) => + _listFuture = FriendApi.getFriendsList(_clientHolder!.client).then((Iterable value) => value.toList() ..sort((a, b) { if (a.userStatus.onlineStatus == b.userStatus.onlineStatus) { @@ -43,74 +55,102 @@ class _HomeScreenState extends State { }, ), ); + _friendFuture = _listFuture; + } + + void _searchForUsers(String needle) { + _listFuture = UserApi.searchUsers(_clientHolder!.client, needle: needle).then((value) => + value.toList() + ..sort((a, b) { + return a.username.length.compareTo(b.username.length); + },) + ); + } + + void _restoreFriendsList() { + _listFuture = _friendFuture; } @override Widget build(BuildContext context) { - final apiClient = ClientHolder.of(context).client; return Scaffold( appBar: AppBar( - title: const Text("Contacts+"), + title: const Text("Contacts++"), ), - body: RefreshIndicator( - onRefresh: () async { - _refreshFriendsList(); - await _friendsFuture; - }, - child: FutureBuilder( - future: _friendsFuture, - builder: (context, snapshot) { - if (snapshot.hasData) { - final data = snapshot.data as Iterable; - return ListView.builder( - itemCount: data.length, - itemBuilder: (context, index) { - final entry = data.elementAt(index); - final iconUri = entry.userProfile.httpIconUri.toString(); - return ListTile( - leading: CachedNetworkImage( - imageBuilder: (context, imageProvider) { - return CircleAvatar( - foregroundImage: imageProvider, - ); - }, - imageUrl: iconUri, - placeholder: (context, url) { - return const CircleAvatar(backgroundColor: Colors.white54,); - }, - errorWidget: (context, error, what) => const CircleAvatar( - backgroundColor: Colors.transparent, - child: Icon(Icons.person), - ), - ), - title: Text(entry.username), - subtitle: Text(entry.userStatus.onlineStatus.name), - onTap: () { - Navigator.of(context).push(MaterialPageRoute(builder: (context) => Messages(friend: entry))); + body: Stack( + children: [ + RefreshIndicator( + onRefresh: () async { + _refreshFriendsList(); + await _listFuture; + }, + child: FutureBuilder( + future: _listFuture, + builder: (context, snapshot) { + if (snapshot.hasData) { + final data = snapshot.data as Iterable; + return ListView.builder( + itemCount: data.length, + itemBuilder: (context, index) { + final entry = data.elementAt(index); + if (entry is Friend) { + return FriendListTile(friend: entry); + } else if (entry is User) { + return UserListTile(user: entry); + } + return null; }, ); - }, - ); - } else if (snapshot.hasError) { - return Center( - child: Padding( - padding: const EdgeInsets.all(64), - child: Text( - "Something went wrong: ${snapshot.error}", - softWrap: true, - style: Theme - .of(context) - .textTheme - .labelMedium, - ), - ), - ); - } else { - return const LinearProgressIndicator(); - } - } - ), + } else if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(64), + child: Text( + "Something went wrong: ${snapshot.error}", + softWrap: true, + style: Theme + .of(context) + .textTheme + .labelMedium, + ), + ), + ); + } else { + return const LinearProgressIndicator(); + } + } + ), + ), + Align( + alignment: Alignment.bottomCenter, + child: ExpandingInputFab( + onInputChanged: (String text) { + if (_debouncer?.isActive ?? false) _debouncer?.cancel(); + if (text.isEmpty) { + setState(() { + _restoreFriendsList(); + }); + } + _debouncer = Timer(const Duration(milliseconds: 500), () { + setState(() { + if (text.isNotEmpty) { + _searchForUsers(text); + } + }); + }); + }, + onExpansionChanged: (expanded) { + if (_debouncer?.isActive ?? false) _debouncer?.cancel(); + if (!expanded) { + setState(() { + _restoreFriendsList(); + }); + } + }, + ), + ), + ], ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/user_list_tile.dart b/lib/widgets/user_list_tile.dart new file mode 100644 index 0000000..534678e --- /dev/null +++ b/lib/widgets/user_list_tile.dart @@ -0,0 +1,50 @@ + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:contacts_plus/models/user.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +class UserListTile extends StatefulWidget { + const UserListTile({required this.user, super.key}); + + final User user; + + @override + State createState() => _UserListTileState(); +} + +class _UserListTileState extends State { + final DateFormat _regDateFormat = DateFormat.yMMMMd('en_US'); + late bool _localAdded = widget.user.userProfile != null; + + @override + Widget build(BuildContext context) { + return ListTile( + leading: CachedNetworkImage( + imageBuilder: (context, imageProvider) { + return CircleAvatar( + foregroundImage: imageProvider, + ); + }, + imageUrl: widget.user.userProfile?.httpIconUri.toString() ?? "", + placeholder: (context, url) { + return const CircleAvatar(backgroundColor: Colors.white54,); + }, + errorWidget: (context, error, what) => const CircleAvatar( + backgroundColor: Colors.transparent, + child: Icon(Icons.person), + ), + ), + title: Text(widget.user.username), + subtitle: Text(_regDateFormat.format(widget.user.registrationDate)), + trailing: IconButton( + onPressed: () { + setState(() { + _localAdded = !_localAdded; + }); + }, + icon: _localAdded ? const Icon(Icons.person_remove_alt_1) : const Icon(Icons.person_add_alt_1), + ), + ); + } +} \ No newline at end of file