Add user search
This commit is contained in:
parent
8a3ff70523
commit
da12adbfaa
7 changed files with 310 additions and 64 deletions
13
lib/apis/user_api.dart
Normal file
13
lib/apis/user_api.dart
Normal 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
25
lib/models/user.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -13,6 +13,7 @@ class UserProfile {
|
||||||
final fullUri = iconUrl.replaceFirst("neosdb:///", Config.neosCdnUrl);
|
final fullUri = iconUrl.replaceFirst("neosdb:///", Config.neosCdnUrl);
|
||||||
final lastPeriodIndex = fullUri.lastIndexOf(".");
|
final lastPeriodIndex = fullUri.lastIndexOf(".");
|
||||||
if (lastPeriodIndex != -1 && fullUri.length - lastPeriodIndex < 8) {
|
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.substring(0, lastPeriodIndex));
|
||||||
}
|
}
|
||||||
return Uri.parse(fullUri);
|
return Uri.parse(fullUri);
|
||||||
|
|
80
lib/widgets/expanding_input_fab.dart
Normal file
80
lib/widgets/expanding_input_fab.dart
Normal 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),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
37
lib/widgets/friend_list_tile.dart
Normal file
37
lib/widgets/friend_list_tile.dart
Normal 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)));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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/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/main.dart';
|
||||||
import 'package:contacts_plus/models/friend.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';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class HomeScreen extends StatefulWidget {
|
class HomeScreen extends StatefulWidget {
|
||||||
|
@ -14,8 +18,16 @@ class HomeScreen extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _HomeScreenState extends State<HomeScreen> {
|
class _HomeScreenState extends State<HomeScreen> {
|
||||||
Future<List<Friend>>? _friendsFuture;
|
Future<List>? _listFuture;
|
||||||
|
Future<List>? _friendFuture;
|
||||||
ClientHolder? _clientHolder;
|
ClientHolder? _clientHolder;
|
||||||
|
Timer? _debouncer;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_debouncer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
|
@ -28,7 +40,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
void _refreshFriendsList() {
|
void _refreshFriendsList() {
|
||||||
_friendsFuture = FriendApi.getFriendsList(_clientHolder!.client).then((Iterable<Friend> value) =>
|
_listFuture = FriendApi.getFriendsList(_clientHolder!.client).then((Iterable<Friend> value) =>
|
||||||
value.toList()
|
value.toList()
|
||||||
..sort((a, b) {
|
..sort((a, b) {
|
||||||
if (a.userStatus.onlineStatus == b.userStatus.onlineStatus) {
|
if (a.userStatus.onlineStatus == b.userStatus.onlineStatus) {
|
||||||
|
@ -43,73 +55,101 @@ 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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final apiClient = ClientHolder.of(context).client;
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text("Contacts+"),
|
title: const Text("Contacts++"),
|
||||||
),
|
),
|
||||||
body: RefreshIndicator(
|
body: Stack(
|
||||||
onRefresh: () async {
|
children: [
|
||||||
_refreshFriendsList();
|
RefreshIndicator(
|
||||||
await _friendsFuture;
|
onRefresh: () async {
|
||||||
},
|
_refreshFriendsList();
|
||||||
child: FutureBuilder(
|
await _listFuture;
|
||||||
future: _friendsFuture,
|
},
|
||||||
builder: (context, snapshot) {
|
child: FutureBuilder(
|
||||||
if (snapshot.hasData) {
|
future: _listFuture,
|
||||||
final data = snapshot.data as Iterable<Friend>;
|
builder: (context, snapshot) {
|
||||||
return ListView.builder(
|
if (snapshot.hasData) {
|
||||||
itemCount: data.length,
|
final data = snapshot.data as Iterable;
|
||||||
itemBuilder: (context, index) {
|
return ListView.builder(
|
||||||
final entry = data.elementAt(index);
|
itemCount: data.length,
|
||||||
final iconUri = entry.userProfile.httpIconUri.toString();
|
itemBuilder: (context, index) {
|
||||||
return ListTile(
|
final entry = data.elementAt(index);
|
||||||
leading: CachedNetworkImage(
|
if (entry is Friend) {
|
||||||
imageBuilder: (context, imageProvider) {
|
return FriendListTile(friend: entry);
|
||||||
return CircleAvatar(
|
} else if (entry is User) {
|
||||||
foregroundImage: imageProvider,
|
return UserListTile(user: entry);
|
||||||
);
|
}
|
||||||
},
|
return null;
|
||||||
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)));
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
} else if (snapshot.hasError) {
|
||||||
);
|
return Center(
|
||||||
} else if (snapshot.hasError) {
|
child: Padding(
|
||||||
return Center(
|
padding: const EdgeInsets.all(64),
|
||||||
child: Padding(
|
child: Text(
|
||||||
padding: const EdgeInsets.all(64),
|
"Something went wrong: ${snapshot.error}",
|
||||||
child: Text(
|
softWrap: true,
|
||||||
"Something went wrong: ${snapshot.error}",
|
style: Theme
|
||||||
softWrap: true,
|
.of(context)
|
||||||
style: Theme
|
.textTheme
|
||||||
.of(context)
|
.labelMedium,
|
||||||
.textTheme
|
),
|
||||||
.labelMedium,
|
),
|
||||||
),
|
);
|
||||||
),
|
} else {
|
||||||
);
|
return const LinearProgressIndicator();
|
||||||
} 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
50
lib/widgets/user_list_tile.dart
Normal file
50
lib/widgets/user_list_tile.dart
Normal 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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue