diff --git a/lib/api_client.dart b/lib/api_client.dart index a1b9a00..523ea58 100644 --- a/lib/api_client.dart +++ b/lib/api_client.dart @@ -9,7 +9,6 @@ import 'package:signalr_netcore/http_connection_options.dart'; import 'package:signalr_netcore/hub_connection.dart'; import 'package:signalr_netcore/hub_connection_builder.dart'; import 'package:signalr_netcore/ihub_protocol.dart'; -import 'package:signalr_netcore/msgpack_hub_protocol.dart'; import 'package:signalr_netcore/web_supporting_http_client.dart'; import 'package:uuid/uuid.dart'; import 'package:logging/logging.dart'; @@ -24,7 +23,7 @@ class ApiClient { ApiClient({required AuthenticationData authenticationData}) : _authenticationData = authenticationData { if (_authenticationData.isAuthenticated) { - hub.start(); + //hub.start(); } } @@ -160,6 +159,7 @@ class NeosHub { hubConnection.onreconnected(({connectionId}) { log("onreconnected called"); }); + hubConnection.on("ReceiveMessage", _handleReceiveMessage); } void start() { @@ -171,4 +171,12 @@ class NeosHub { Future sendMessage(Message message) async { await hubConnection.send("SendMessage", args: [message.toMap()]); } + + void _handleReceiveMessage(List? params) { + log("Message received."); + if (params == null) return; + for(var obj in params) { + log("$obj"); + } + } } diff --git a/lib/apis/friend_api.dart b/lib/apis/friend_api.dart index 3e920ba..36f7feb 100644 --- a/lib/apis/friend_api.dart +++ b/lib/apis/friend_api.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:contacts_plus/api_client.dart'; import 'package:contacts_plus/models/friend.dart'; +import 'package:contacts_plus/models/user.dart'; class FriendApi { static Future> getFriendsList(ApiClient client) async { @@ -11,4 +12,9 @@ class FriendApi { final data = jsonDecode(response.body) as List; return data.map((e) => Friend.fromMap(e)); } + + static Future addFriend(ApiClient client, {required User user}) async { + final response = await client.put("/users/${client.userId}/friends/${user.id}", body: user.toMap()); + ApiClient.checkResponse(response); + } } \ No newline at end of file diff --git a/lib/aux.dart b/lib/aux.dart index d6ca95d..73ff33f 100644 --- a/lib/aux.dart +++ b/lib/aux.dart @@ -40,4 +40,16 @@ extension NeosStringExtensions on Uri { return Uri.parse(base + signature); } +} + +class Aux { + static String neosDbToHttp(String neosdb) { + final fullUri = neosdb.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 fullUri.substring(0, lastPeriodIndex); + } + return fullUri; + } } \ No newline at end of file diff --git a/lib/config.dart b/lib/config.dart index 6606e38..d09ba8a 100644 --- a/lib/config.dart +++ b/lib/config.dart @@ -6,4 +6,6 @@ class Config { static const String neosCdnUrl = "https://cloudx.azureedge.net/assets/"; static const String neosAssetsUrl = "https://cloudxstorage.blob.core.windows.net/assets/"; static const String neosHubUrl = "$apiBaseUrl/hub"; + + static const int messageCacheValiditySeconds = 90; } \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index edb0f8c..6342ec5 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,3 +1,4 @@ +import 'package:contacts_plus/models/message.dart'; import 'package:contacts_plus/widgets/home_screen.dart'; import 'package:contacts_plus/widgets/login_screen.dart'; import 'package:flutter/material.dart'; @@ -18,27 +19,33 @@ class ContactsPlus extends StatefulWidget { class _ContactsPlusState extends State { final Typography _typography = Typography.material2021(platform: TargetPlatform.android); AuthenticationData _authData = AuthenticationData.unauthenticated(); + final Map _messageCache = {}; @override Widget build(BuildContext context) { return ClientHolder( authenticationData: _authData, - child: MaterialApp( - title: 'Contacts+', - theme: ThemeData( + child: MessageCacheHolder( + messageCache: _messageCache, + child: MaterialApp( + debugShowCheckedModeBanner: false, + title: 'Contacts+', + theme: ThemeData( + useMaterial3: true, textTheme: _typography.white, colorScheme: ColorScheme.fromSeed(seedColor: Colors.purple, brightness: Brightness.dark) - ), - home: _authData.isAuthenticated ? - const HomeScreen() : - LoginScreen( - onLoginSuccessful: (AuthenticationData authData) { - if (authData.isAuthenticated) { - setState(() { - _authData = authData; - }); - } - }, + ), + home: _authData.isAuthenticated ? + const HomeScreen() : + LoginScreen( + onLoginSuccessful: (AuthenticationData authData) { + if (authData.isAuthenticated) { + setState(() { + _authData = authData; + }); + } + }, + ), ), ), ); @@ -63,4 +70,33 @@ class ClientHolder extends InheritedWidget { @override bool updateShouldNotify(covariant ClientHolder oldWidget) => oldWidget.client != client; +} + + +class MessageCacheHolder extends InheritedWidget { + const MessageCacheHolder({super.key, required Map messageCache, required super.child}) + : _messageCache = messageCache; + + final Map _messageCache; + + MessageCache? getCache(String index) => _messageCache[index]; + + void setCache(String index, List messages) { + _messageCache[index]?.invalidate(); + _messageCache[index] = MessageCache(messages: messages); + } + + static MessageCacheHolder? maybeOf(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType(); + } + + static MessageCacheHolder of(BuildContext context) { + final MessageCacheHolder? result = maybeOf(context); + assert(result != null, 'No MessageCacheHolder found in context'); + return result!; + } + + @override + bool updateShouldNotify(covariant InheritedWidget oldWidget) => false; + } \ No newline at end of file diff --git a/lib/models/friend.dart b/lib/models/friend.dart index f983cb9..8f3ff8a 100644 --- a/lib/models/friend.dart +++ b/lib/models/friend.dart @@ -7,15 +7,19 @@ class Friend extends Comparable { final String username; final UserStatus userStatus; final UserProfile userProfile; + final FriendStatus friendStatus; - Friend({required this.id, required this.username, required this.userStatus, required this.userProfile}); + Friend({required this.id, required this.username, required this.userStatus, required this.userProfile, + required this.friendStatus, + }); factory Friend.fromMap(Map map) { return Friend( id: map["id"], username: map["friendUsername"], userStatus: UserStatus.fromMap(map["userStatus"]), - userProfile: UserProfile.fromMap(map["profile"] ?? {"iconUrl": ""}), + userProfile: UserProfile.fromMap(map["profile"] ?? {}), + friendStatus: FriendStatus.fromString(map["friendStatus"]), ); } @@ -33,31 +37,89 @@ class Friend extends Comparable { } } +class Session { + final String id; + final String name; + final List sessionUsers; + final String thumbnail; + + Session({required this.id, required this.name, required this.sessionUsers, required this.thumbnail}); + + factory Session.fromMap(Map map) { + return Session( + id: map["sessionId"], + name: map["name"], + sessionUsers: (map["sessionUsers"] as List? ?? []).map((entry) => SessionUser.fromMap(entry)).toList(), + thumbnail: map["thumbnail"] + ); + } +} + +class SessionUser { + final String id; + final String username; + final bool isPresent; + final int outputDevice; + + SessionUser({required this.id, required this.username, required this.isPresent, required this.outputDevice}); + + factory SessionUser.fromMap(Map map) { + return SessionUser( + id: map["userID"], + username: map["username"], + isPresent: map["isPresent"], + outputDevice: map["outputDevice"], + ); + } +} + +enum FriendStatus { + none, + searchResult, + requested, + ignored, + blocked, + accepted; + + factory FriendStatus.fromString(String text) { + return FriendStatus.values.firstWhere((element) => element.name.toLowerCase() == text.toLowerCase(), + orElse: () => FriendStatus.none, + ); + } +} + enum OnlineStatus { unknown, offline, away, busy, - online, + online; + + factory OnlineStatus.fromString(String? text) { + return OnlineStatus.values.firstWhere((element) => element.name.toLowerCase() == text?.toLowerCase(), + orElse: () => OnlineStatus.unknown, + ); + } } class UserStatus { final OnlineStatus onlineStatus; final DateTime lastStatusChange; + final List activeSessions; - UserStatus({required this.onlineStatus, required this.lastStatusChange}); + + UserStatus({required this.onlineStatus, required this.lastStatusChange, required this.activeSessions}); factory UserStatus.fromMap(Map map) { final statusString = map["onlineStatus"] as String?; - final status = OnlineStatus.values.firstWhere((element) => element.name.toLowerCase() == statusString?.toLowerCase(), - orElse: () => OnlineStatus.unknown, - ); + final status = OnlineStatus.fromString(statusString); if (status == OnlineStatus.unknown && statusString != null) { log("Unknown OnlineStatus '$statusString' in response"); } return UserStatus( onlineStatus: status, lastStatusChange: DateTime.parse(map["lastStatusChange"]), + activeSessions: (map["activeSessions"] as List? ?? []).map((e) => Session.fromMap(e)).toList(), ); } } \ No newline at end of file diff --git a/lib/models/message.dart b/lib/models/message.dart index 0c106bf..cfb7688 100644 --- a/lib/models/message.dart +++ b/lib/models/message.dart @@ -1,5 +1,7 @@ +import 'dart:async'; import 'dart:developer'; +import 'package:contacts_plus/config.dart'; import 'package:uuid/uuid.dart'; enum MessageType { @@ -67,4 +69,19 @@ class Message { static String generateId() { return "MSG-${const Uuid().v4()}"; } +} + +class MessageCache { + late final Timer _timer; + final List _messages; + bool get isValid => _timer.isActive; + + List get messages => _messages; + + MessageCache({required List messages}) + : _messages = messages, _timer = Timer(const Duration(seconds: Config.messageCacheValiditySeconds),() {}); + + void invalidate() { + _timer.cancel(); + } } \ No newline at end of file diff --git a/lib/models/user.dart b/lib/models/user.dart index a6268d5..81fda11 100644 --- a/lib/models/user.dart +++ b/lib/models/user.dart @@ -22,4 +22,13 @@ class User { userProfile: profile, ); } + + Map toMap() { + return { + "id": id, + "username": username, + "registrationDate": registrationDate.toIso8601String(), + "profile": userProfile?.toMap(), + }; + } } \ No newline at end of file diff --git a/lib/models/user_profile.dart b/lib/models/user_profile.dart index 273b4e0..2ff9bd6 100644 --- a/lib/models/user_profile.dart +++ b/lib/models/user_profile.dart @@ -1,21 +1,15 @@ -import 'package:contacts_plus/config.dart'; - class UserProfile { final String iconUrl; UserProfile({required this.iconUrl}); factory UserProfile.fromMap(Map map) { - return UserProfile(iconUrl: map["iconUrl"]); + return UserProfile(iconUrl: map["iconUrl"] ?? ""); } - Uri get httpIconUri { - 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); + Map toMap() { + return { + "iconUrl": iconUrl, + }; } } \ No newline at end of file diff --git a/lib/widgets/expanding_input_fab.dart b/lib/widgets/expanding_input_fab.dart index efd75f4..a8d11f5 100644 --- a/lib/widgets/expanding_input_fab.dart +++ b/lib/widgets/expanding_input_fab.dart @@ -26,10 +26,10 @@ class _ExpandingInputFabState extends State { children: [ AnimatedSize( alignment: Alignment.bottomRight, - duration: const Duration(milliseconds: 300), - reverseDuration: const Duration(milliseconds: 300), - curve: Curves.easeOut, - child: Container( + duration: const Duration(milliseconds: 200), + reverseDuration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + child: Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(20), color: Theme.of(context).colorScheme.secondaryContainer, diff --git a/lib/widgets/friend_list_tile.dart b/lib/widgets/friend_list_tile.dart index 6cbf4d9..2421dd5 100644 --- a/lib/widgets/friend_list_tile.dart +++ b/lib/widgets/friend_list_tile.dart @@ -1,5 +1,7 @@ import 'package:cached_network_image/cached_network_image.dart'; +import 'package:contacts_plus/aux.dart'; import 'package:contacts_plus/models/friend.dart'; +import 'package:contacts_plus/widgets/generic_avatar.dart'; import 'package:contacts_plus/widgets/messages.dart'; import 'package:flutter/material.dart'; @@ -10,22 +12,9 @@ class FriendListTile extends StatelessWidget { @override Widget build(BuildContext context) { + final imageUri = Aux.neosDbToHttp(friend.userProfile.iconUrl); 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), - ), - ), + leading: GenericAvatar(imageUri: imageUri,), title: Text(friend.username), subtitle: Text(friend.userStatus.onlineStatus.name), onTap: () { @@ -33,5 +22,4 @@ class FriendListTile extends StatelessWidget { }, ); } - } \ No newline at end of file diff --git a/lib/widgets/generic_avatar.dart b/lib/widgets/generic_avatar.dart new file mode 100644 index 0000000..8b5f796 --- /dev/null +++ b/lib/widgets/generic_avatar.dart @@ -0,0 +1,33 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +class GenericAvatar extends StatelessWidget { + const GenericAvatar({this.imageUri="", super.key}); + + final String imageUri; + + @override + Widget build(BuildContext context) { + return imageUri.isEmpty ? const CircleAvatar( + backgroundColor: Colors.transparent, + child: Icon(Icons.person), + ) : CachedNetworkImage( + imageBuilder: (context, imageProvider) { + return CircleAvatar( + foregroundImage: imageProvider, + backgroundColor: Colors.transparent, + ); + }, + imageUrl: imageUri, + placeholder: (context, url) { + return const CircleAvatar(backgroundColor: Colors.white54,); + }, + errorWidget: (context, error, what) => const CircleAvatar( + backgroundColor: Colors.transparent, + child: Icon(Icons.person), + ), + ); + } + +} \ No newline at end of file diff --git a/lib/widgets/home_screen.dart b/lib/widgets/home_screen.dart index ecef68c..2d57344 100644 --- a/lib/widgets/home_screen.dart +++ b/lib/widgets/home_screen.dart @@ -22,6 +22,7 @@ class _HomeScreenState extends State { Future? _friendFuture; ClientHolder? _clientHolder; Timer? _debouncer; + bool _searchIsLoading = false; @override void dispose() { @@ -40,6 +41,7 @@ class _HomeScreenState extends State { } void _refreshFriendsList() { + _searchIsLoading = true; _listFuture = FriendApi.getFriendsList(_clientHolder!.client).then((Iterable value) => value.toList() ..sort((a, b) { @@ -54,7 +56,7 @@ class _HomeScreenState extends State { } }, ), - ); + ).whenComplete(() => setState((){ _searchIsLoading = false; })); _friendFuture = _listFuture; } @@ -64,7 +66,7 @@ class _HomeScreenState extends State { ..sort((a, b) { return a.username.length.compareTo(b.username.length); },) - ); + ).whenComplete(() => setState((){ _searchIsLoading = false; })); } void _restoreFriendsList() { @@ -102,6 +104,7 @@ class _HomeScreenState extends State { }, ); } else if (snapshot.hasError) { + FlutterError.reportError(FlutterErrorDetails(exception: snapshot.error!, stack: snapshot.stackTrace)); return Center( child: Padding( padding: const EdgeInsets.all(64), @@ -116,7 +119,7 @@ class _HomeScreenState extends State { ), ); } else { - return const LinearProgressIndicator(); + return const SizedBox.shrink(); } } ), @@ -128,15 +131,22 @@ class _HomeScreenState extends State { if (_debouncer?.isActive ?? false) _debouncer?.cancel(); if (text.isEmpty) { setState(() { + _searchIsLoading = false; _restoreFriendsList(); }); + return; } + setState(() { + _searchIsLoading = true; + }); _debouncer = Timer(const Duration(milliseconds: 500), () { - setState(() { - if (text.isNotEmpty) { - _searchForUsers(text); - } - }); + setState(() { + if(text.isNotEmpty) { + _searchForUsers(text); + } else { + _searchIsLoading = false; + } + }); }); }, onExpansionChanged: (expanded) { @@ -149,6 +159,7 @@ class _HomeScreenState extends State { }, ), ), + if (_searchIsLoading) const Align(alignment: Alignment.topCenter, child: LinearProgressIndicator(),) ], ), ); diff --git a/lib/widgets/messages.dart b/lib/widgets/messages.dart index 72f1623..b597876 100644 --- a/lib/widgets/messages.dart +++ b/lib/widgets/messages.dart @@ -1,8 +1,12 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:contacts_plus/apis/message_api.dart'; +import 'package:contacts_plus/aux.dart'; import 'package:contacts_plus/main.dart'; import 'package:contacts_plus/models/friend.dart'; import 'package:contacts_plus/models/message.dart'; +import 'package:contacts_plus/widgets/generic_avatar.dart'; import 'package:flutter/material.dart'; +import 'package:http/http.dart'; import 'package:intl/intl.dart'; class Messages extends StatefulWidget { @@ -12,36 +16,111 @@ class Messages extends StatefulWidget { @override State createState() => _MessagesState(); - } class _MessagesState extends State { + static const double headerItemSize = 300.0; Future>? _messagesFuture; final TextEditingController _messageTextController = TextEditingController(); ClientHolder? _clientHolder; + MessageCacheHolder? _cacheHolder; + bool _headerExpanded = false; bool _isSendable = false; + double get _headerHeight => _headerExpanded ? headerItemSize : 0; + double get _chevronTurns => _headerExpanded ? -1/4 : 1/4; + void _refreshMessages() { - _messagesFuture = MessageApi.getUserMessages(_clientHolder!.client, userId: widget.friend.id)..then((value) => value.toList()); + final cache = _cacheHolder?.getCache(widget.friend.id); + if (cache?.isValid ?? false) { + _messagesFuture = Future(() => cache!.messages); + } else { + _messagesFuture = MessageApi.getUserMessages(_clientHolder!.client, userId: widget.friend.id) + ..then((value) { + final list = value.toList(); + _cacheHolder?.setCache(widget.friend.id, list); + return list; + }); + } } @override void didChangeDependencies() { super.didChangeDependencies(); final clientHolder = ClientHolder.of(context); + bool dirty = false; if (_clientHolder != clientHolder) { _clientHolder = clientHolder; - _refreshMessages(); + dirty = true; } + final cacheHolder = MessageCacheHolder.of(context); + if (_cacheHolder != cacheHolder) { + _cacheHolder = cacheHolder; + dirty = true; + } + if (dirty) _refreshMessages(); } @override Widget build(BuildContext context) { final apiClient = ClientHolder.of(context).client; + var sessions = widget.friend.userStatus.activeSessions; return Scaffold( appBar: AppBar( title: Text(widget.friend.username), + actions: [ + if(sessions.isNotEmpty) AnimatedRotation( + turns: _chevronTurns, + curve: Curves.easeOutCirc, + duration: const Duration(milliseconds: 250), + child: IconButton( + onPressed: () { + setState(() { + _headerExpanded = !_headerExpanded; + }); + }, + icon: const Icon(Icons.chevron_right), + ), + ) + ], + scrolledUnderElevation: 0.0, + backgroundColor: Theme.of(context).colorScheme.surfaceVariant, + bottom: sessions.isEmpty ? null : PreferredSize( + preferredSize: Size.fromHeight(_headerHeight), + child: AnimatedContainer( + height: _headerHeight, + duration: const Duration(milliseconds: 400), + child: Column( + children: sessions.getRange(0, _headerExpanded ? sessions.length : 1).map((e) => Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: GenericAvatar(imageUri: Aux.neosDbToHttp(e.thumbnail),), + ), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(e.name), + Text("${e.sessionUsers.length} users active"), + ], + ), + ), + const Spacer(), + if (sessions.length > 1) TextButton(onPressed: (){ + setState(() { + _headerExpanded = !_headerExpanded; + }); + }, child: Text("+${sessions.length-1}"),) + ], + )).toList(), + ), + ), + ), ), body: FutureBuilder( future: _messagesFuture, @@ -59,19 +138,34 @@ class _MessagesState extends State { }, ); } else if (snapshot.hasError) { - return Column( - children: [ - Text("Failed to load messages:\n${snapshot.error}"), - TextButton.icon( - onPressed: () { - setState(() { - _refreshMessages(); - }); - }, - icon: const Icon(Icons.refresh), - label: const Text("Retry"), + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 64, vertical: 128), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text("Failed to load messages:", style: Theme.of(context).textTheme.titleMedium,), + const SizedBox(height: 16,), + Text("${snapshot.error}"), + const Spacer(), + TextButton.icon( + onPressed: () { + setState(() { + _refreshMessages(); + }); + }, + style: TextButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.secondaryContainer, + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16), + ), + icon: const Icon(Icons.refresh), + label: const Text("Retry"), + ), + ], ), - ], + ), ); } else { return const LinearProgressIndicator(); @@ -79,11 +173,12 @@ class _MessagesState extends State { }, ), bottomNavigationBar: BottomAppBar( + padding: const EdgeInsets.symmetric(vertical: 0, horizontal: 6), child: Row( children: [ Expanded( child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 8, 8), + padding: const EdgeInsets.all(8), child: TextField( controller: _messageTextController, maxLines: 4, @@ -102,17 +197,16 @@ class _MessagesState extends State { decoration: InputDecoration( isDense: true, hintText: "Send a message to ${widget.friend.username}...", - hintStyle: Theme.of(context).textTheme.labelLarge?.copyWith(color: Colors.white54), - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + contentPadding: const EdgeInsets.all(16), border: OutlineInputBorder( - borderRadius: BorderRadius.circular(24), - ), + borderRadius: BorderRadius.circular(24) + ) ), ), ), ), Padding( - padding: const EdgeInsets.only(right: 8.0), + padding: const EdgeInsets.only(left: 8, right: 4.0), child: IconButton( splashRadius: 24, onPressed: _isSendable ? () async { diff --git a/lib/widgets/user_list_tile.dart b/lib/widgets/user_list_tile.dart index 534678e..1289e68 100644 --- a/lib/widgets/user_list_tile.dart +++ b/lib/widgets/user_list_tile.dart @@ -1,6 +1,5 @@ - -import 'package:cached_network_image/cached_network_image.dart'; import 'package:contacts_plus/models/user.dart'; +import 'package:contacts_plus/widgets/generic_avatar.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; @@ -20,21 +19,7 @@ class _UserListTileState extends State { @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), - ), - ), + leading: GenericAvatar(imageUri: widget.user.userProfile?.iconUrl ?? "",), title: Text(widget.user.username), subtitle: Text(_regDateFormat.format(widget.user.registrationDate)), trailing: IconButton( @@ -43,6 +28,7 @@ class _UserListTileState extends State { _localAdded = !_localAdded; }); }, + splashRadius: 24, icon: _localAdded ? const Icon(Icons.person_remove_alt_1) : const Icon(Icons.person_add_alt_1), ), );