Add user search

This commit is contained in:
Nutcake 2023-04-30 15:43:59 +02:00 committed by Nils Rother
parent 8a3ff70523
commit da12adbfaa
7 changed files with 310 additions and 64 deletions

13
lib/apis/user_api.dart Normal file
View file

@ -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<Iterable<User>> 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));
}
}

25
lib/models/user.dart Normal file
View file

@ -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,
);
}
}

View file

@ -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);

View file

@ -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<StatefulWidget> createState() => _ExpandingInputFabState();
}
class _ExpandingInputFabState extends State<ExpandingInputFab> {
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),
),
)
],
),
),
),
],
),
);
}
}

View file

@ -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)));
},
);
}
}

View file

@ -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<HomeScreen> {
Future<List<Friend>>? _friendsFuture;
Future<List>? _listFuture;
Future<List>? _friendFuture;
ClientHolder? _clientHolder;
Timer? _debouncer;
@override
void dispose() {
_debouncer?.cancel();
super.dispose();
}
@override
void didChangeDependencies() {
@ -28,7 +40,7 @@ class _HomeScreenState extends State<HomeScreen> {
}
void _refreshFriendsList() {
_friendsFuture = FriendApi.getFriendsList(_clientHolder!.client).then((Iterable<Friend> value) =>
_listFuture = FriendApi.getFriendsList(_clientHolder!.client).then((Iterable<Friend> value) =>
value.toList()
..sort((a, b) {
if (a.userStatus.onlineStatus == b.userStatus.onlineStatus) {
@ -43,74 +55,102 @@ class _HomeScreenState extends State<HomeScreen> {
},
),
);
_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<Friend>;
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();
});
}
},
),
),
],
),
);
}
}
}

View file

@ -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<UserListTile> createState() => _UserListTileState();
}
class _UserListTileState extends State<UserListTile> {
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),
),
);
}
}