Merge branch 'krille/onhistoryreceived' into 'main'

refactor: Implement on history received in timeline

See merge request famedly/company/frontend/famedlysdk!949
This commit is contained in:
Krille Fear 2022-02-03 10:00:46 +00:00
commit eed96745d1
6 changed files with 340 additions and 191 deletions

View File

@ -1,3 +1,9 @@
## [0.8.1] - 03nd Feb 2022
- refactor: Implement on history received in timeline
- fix: null-safety issues with widgets
- fix: Trigger onChange for index on aggregation event update
- feat: implement to get a room's widgets
## [0.8.0] - 25nd Jan 2022 ## [0.8.0] - 25nd Jan 2022
- BREAKING CHANGE: high-level hadling of image sizes - BREAKING CHANGE: high-level hadling of image sizes
- feat: expose Timeline.onChange to Room.getTimeline - feat: expose Timeline.onChange to Room.getTimeline

View File

@ -1,154 +1,219 @@
import 'package:matrix/matrix.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
import 'package:path_provider/path_provider.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
void main() { void main() async {
runApp(FamedlySdkExampleApp()); WidgetsFlutterBinding.ensureInitialized();
final client = Client(
'Matrix Example Chat',
databaseBuilder: (_) async {
final dir = await getApplicationSupportDirectory();
final db = FluffyBoxDatabase('matrix_example_chat', dir.path);
await db.open();
return db;
},
);
await client.init();
runApp(MatrixExampleChat(client: client));
} }
class FamedlySdkExampleApp extends StatelessWidget { class MatrixExampleChat extends StatelessWidget {
final Client client;
const MatrixExampleChat({required this.client, Key? key}) : super(key: key);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Provider<Client>( return MaterialApp(
create: (_) => Client('Famedly SDK Example App'), title: 'Matrix Example Chat',
child: Builder( builder: (context, child) => Provider<Client>(
builder: (context) => MaterialApp( create: (context) => client,
title: 'Famedly SDK Example App', child: child,
home: StreamBuilder<LoginState>(
stream: Provider.of<Client>(context).onLoginStateChanged.stream,
builder:
(BuildContext context, AsyncSnapshot<LoginState> snapshot) {
if (snapshot.hasError) {
return Center(child: Text(snapshot.error.toString()));
}
if (snapshot.data == LoginState.loggedIn) {
return ChatListView();
}
return LoginView();
},
),
),
), ),
home: client.isLogged() ? const RoomListPage() : const LoginPage(),
); );
} }
} }
class LoginView extends StatefulWidget { class LoginPage extends StatefulWidget {
const LoginPage({Key? key}) : super(key: key);
@override @override
_LoginViewState createState() => _LoginViewState(); _LoginPageState createState() => _LoginPageState();
} }
class _LoginViewState extends State<LoginView> { class _LoginPageState extends State<LoginPage> {
final TextEditingController _usernameController = TextEditingController(), final TextEditingController _homeserverTextField = TextEditingController(
_passwordController = TextEditingController(), text: 'matrix.org',
_domainController = TextEditingController(); );
final TextEditingController _usernameTextField = TextEditingController();
final TextEditingController _passwordTextField = TextEditingController();
String _errorText; bool _loading = false;
bool _isLoading = false; void _login() async {
void _loginAction(Client client) async {
setState(() { setState(() {
_errorText = null; _loading = true;
_isLoading = true;
}); });
try { try {
await client.checkHomeserver(_domainController.text); final client = Provider.of<Client>(context, listen: false);
await client
.checkHomeserver(Uri.https(_homeserverTextField.text.trim(), ''));
await client.login( await client.login(
user: _usernameController.text, LoginType.mLoginPassword,
password: _passwordController.text, password: _passwordTextField.text,
identifier: AuthenticationUserIdentifier(user: _usernameTextField.text),
);
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const RoomListPage()),
(route) => false,
); );
} catch (e) { } catch (e) {
setState(() => _errorText = e.toString()); ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(e.toString()),
),
);
setState(() {
_loading = false;
});
} }
setState(() => _isLoading = false);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final client = Provider.of<Client>(context);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(title: const Text('Login')),
title: Text('Famedly SDK Example App'), body: Padding(
), padding: const EdgeInsets.all(16.0),
body: ListView( child: Column(
padding: EdgeInsets.all(16),
children: [ children: [
TextField( TextField(
controller: _usernameController, controller: _homeserverTextField,
readOnly: _isLoading, readOnly: _loading,
autocorrect: false, autocorrect: false,
decoration: InputDecoration( decoration: const InputDecoration(
prefixText: 'https://',
border: OutlineInputBorder(),
labelText: 'Homeserver',
),
),
const SizedBox(height: 16),
TextField(
controller: _usernameTextField,
readOnly: _loading,
autocorrect: false,
decoration: const InputDecoration(
border: OutlineInputBorder(), border: OutlineInputBorder(),
labelText: 'Username', labelText: 'Username',
), ),
), ),
SizedBox(height: 16), const SizedBox(height: 16),
TextField( TextField(
controller: _passwordController, controller: _passwordTextField,
readOnly: _isLoading, readOnly: _loading,
autocorrect: false, autocorrect: false,
obscureText: true, obscureText: true,
decoration: InputDecoration( decoration: const InputDecoration(
border: OutlineInputBorder(), border: OutlineInputBorder(),
labelText: 'Password', labelText: 'Password',
), ),
), ),
SizedBox(height: 16), const SizedBox(height: 16),
TextField( SizedBox(
controller: _domainController, width: double.infinity,
readOnly: _isLoading, child: ElevatedButton(
autocorrect: false, onPressed: _loading ? null : _login,
decoration: InputDecoration( child: _loading
border: OutlineInputBorder(), ? const LinearProgressIndicator()
labelText: 'Password', : const Text('Login'),
hintText: 'https://matrix.org',
errorText: _errorText,
errorMaxLines: 4,
), ),
), ),
SizedBox(height: 16),
RaisedButton(
child: _isLoading ? LinearProgressIndicator() : Text('Login'),
onPressed: _isLoading ? null : () => _loginAction(client),
),
], ],
), ),
),
); );
} }
} }
class ChatListView extends StatelessWidget { class RoomListPage extends StatefulWidget {
const RoomListPage({Key? key}) : super(key: key);
@override
_RoomListPageState createState() => _RoomListPageState();
}
class _RoomListPageState extends State<RoomListPage> {
void _logout() async {
final client = Provider.of<Client>(context, listen: false);
await client.logout();
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const LoginPage()),
(route) => false,
);
}
void _join(Room room) async {
if (room.membership != Membership.join) {
await room.join();
}
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => RoomPage(room: room),
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final client = Provider.of<Client>(context); final client = Provider.of<Client>(context, listen: false);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text('Chats'), title: const Text('Chats'),
actions: [
IconButton(
icon: const Icon(Icons.logout),
onPressed: _logout,
),
],
), ),
body: StreamBuilder( body: StreamBuilder(
stream: client.onSync.stream, stream: client.onSync.stream,
builder: (context, _) => ListView.builder( builder: (context, _) => ListView.builder(
itemCount: client.rooms.length, itemCount: client.rooms.length,
itemBuilder: (BuildContext context, int i) => ListTile( itemBuilder: (context, i) => ListTile(
leading: CircleAvatar( leading: CircleAvatar(
backgroundImage: client.rooms[i].avatar == null foregroundImage: client.rooms[i].avatar == null
? null ? null
: NetworkImage( : NetworkImage(client.rooms[i].avatar!
client.rooms[i].avatar.getThumbnail( .getThumbnail(
client, client,
width: 64, width: 56,
height: 64, height: 56,
)
.toString()),
), ),
title: Row(
children: [
Expanded(child: Text(client.rooms[i].displayname)),
if (client.rooms[i].notificationCount > 0)
Material(
borderRadius: BorderRadius.circular(99),
color: Colors.red,
child: Padding(
padding: const EdgeInsets.all(2.0),
child:
Text(client.rooms[i].notificationCount.toString()),
))
],
), ),
subtitle: Text(
client.rooms[i].lastEvent?.body ?? 'No messages',
maxLines: 1,
), ),
title: Text(client.rooms[i].displayname), onTap: () => _join(client.rooms[i]),
subtitle: Text(client.rooms[i].lastMessage),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ChatView(roomId: client.rooms[i].id),
),
),
), ),
), ),
), ),
@ -156,85 +221,157 @@ class ChatListView extends StatelessWidget {
} }
} }
class ChatView extends StatelessWidget { class RoomPage extends StatefulWidget {
final String roomId; final Room room;
const RoomPage({required this.room, Key? key}) : super(key: key);
const ChatView({Key key, @required this.roomId}) : super(key: key); @override
_RoomPageState createState() => _RoomPageState();
}
class _RoomPageState extends State<RoomPage> {
late final Future<Timeline> _timelineFuture;
final GlobalKey<AnimatedListState> _listKey = GlobalKey<AnimatedListState>();
int _count = 0;
@override
void initState() {
_timelineFuture = widget.room.getTimeline(onChange: (i) {
print('on change! $i');
_listKey.currentState?.setState(() {});
}, onInsert: (i) {
print('on insert! $i');
_listKey.currentState?.insertItem(i);
_count++;
}, onRemove: (i) {
print('On remove $i');
_count--;
_listKey.currentState?.removeItem(i, (_, __) => const ListTile());
}, onHistoryReceived: (count) {
print('On History Received $count');
for (var i = 0; i < count; i++) {
_listKey.currentState?.insertItem(_count + i);
}
_count += count;
});
super.initState();
}
final TextEditingController _sendController = TextEditingController();
void _send() {
widget.room.sendTextEvent(_sendController.text.trim());
_sendController.clear();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final client = Provider.of<Client>(context);
final TextEditingController _sendController = TextEditingController();
return StreamBuilder<Object>(
stream: client.onSync.stream,
builder: (context, _) {
final room = client.getRoomById(roomId);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(room.displayname), title: Text(widget.room.displayname),
), ),
body: SafeArea( body: SafeArea(
child: FutureBuilder<Timeline>( child: Column(
future: room.getTimeline(),
builder:
(BuildContext context, AsyncSnapshot<Timeline> snapshot) {
if (!snapshot.hasData) {
return Center(child: CircularProgressIndicator());
}
final timeline = snapshot.data;
return Column(
children: [ children: [
Expanded( Expanded(
child: ListView.builder( child: FutureBuilder<Timeline>(
future: _timelineFuture,
builder: (context, snapshot) {
final timeline = snapshot.data;
if (timeline == null) {
return const Center(
child: CircularProgressIndicator.adaptive(),
);
}
_count = timeline.events.length;
return Column(
children: [
Center(
child: TextButton(
onPressed: timeline.requestHistory,
child: const Text('Load more...')),
),
const Divider(height: 1),
Expanded(
child: AnimatedList(
key: _listKey,
reverse: true, reverse: true,
itemCount: timeline.events.length, initialItemCount: timeline.events.length,
itemBuilder: (BuildContext context, int i) { itemBuilder: (context, i, animation) => timeline
final event = timeline.events[i]; .events[i].relationshipEventId !=
final sender = event.sender; null
return ListTile( ? Container()
: ScaleTransition(
scale: animation,
child: Opacity(
opacity: timeline.events[i].status.isSent
? 1
: 0.5,
child: ListTile(
leading: CircleAvatar( leading: CircleAvatar(
backgroundImage: sender.avatarUrl == null foregroundImage: timeline.events[i]
.sender.avatarUrl ==
null
? null ? null
: NetworkImage( : NetworkImage(timeline
sender.avatarUrl.getThumbnail( .events[i].sender.avatarUrl!
client, .getThumbnail(
width: 64, widget.room.client,
height: 64, width: 56,
height: 56,
)
.toString()),
),
title: Row(
children: [
Expanded(
child: Text(timeline
.events[i].sender
.calcDisplayname()),
),
Text(
timeline.events[i].originServerTs
.toIso8601String(),
style:
const TextStyle(fontSize: 10),
),
],
),
subtitle: Text(timeline.events[i]
.getDisplayEvent(timeline)
.body),
), ),
), ),
), ),
title: Text(sender.calcDisplayname()), ),
subtitle: Text(event.body), ),
],
); );
}, },
), ),
), ),
Divider(height: 1), const Divider(height: 1),
Container( Padding(
height: 56, padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Row( child: Row(
children: [ children: [
Expanded( Expanded(
child: TextField( child: TextField(
controller: _sendController, controller: _sendController,
decoration: const InputDecoration(
hintText: 'Send message',
), ),
), )),
IconButton( IconButton(
icon: Icon(Icons.send), icon: const Icon(Icons.send_outlined),
onPressed: () { onPressed: _send,
room.sendTextEvent(_sendController.text);
_sendController.clear();
},
), ),
], ],
), ),
), ),
], ],
);
},
), ),
), ),
); );
});
} }
} }

View File

@ -983,7 +983,8 @@ class Room {
/// Request more previous events from the server. [historyCount] defines how much events should /// Request more previous events from the server. [historyCount] defines how much events should
/// be received maximum. When the request is answered, [onHistoryReceived] will be triggered **before** /// be received maximum. When the request is answered, [onHistoryReceived] will be triggered **before**
/// the historical events will be published in the onEvent stream. /// the historical events will be published in the onEvent stream.
Future<void> requestHistory( /// Returns the actual count of received timeline events.
Future<int> requestHistory(
{int historyCount = defaultHistoryCount, {int historyCount = defaultHistoryCount,
void Function()? onHistoryReceived}) async { void Function()? onHistoryReceived}) async {
final prev_batch = this.prev_batch; final prev_batch = this.prev_batch;
@ -1044,6 +1045,8 @@ class Room {
} else { } else {
await loadFn(); await loadFn();
} }
return resp.chunk?.length ?? 0;
} }
/// Sets this room as a direct chat for this user if not already. /// Sets this room as a direct chat for this user if not already.
@ -1131,12 +1134,13 @@ class Room {
/// Creates a timeline from the store. Returns a [Timeline] object. If you /// Creates a timeline from the store. Returns a [Timeline] object. If you
/// just want to update the whole timeline on every change, use the [onUpdate] /// just want to update the whole timeline on every change, use the [onUpdate]
/// callback. For updating only the parts that have changed, use the /// callback. For updating only the parts that have changed, use the
/// [onChange], [onRemove] and the [onInsert] callbacks. /// [onChange], [onRemove], [onInsert] and the [onHistoryReceived] callbacks.
Future<Timeline> getTimeline({ Future<Timeline> getTimeline({
void Function(int index)? onChange, void Function(int index)? onChange,
void Function(int index)? onRemove, void Function(int index)? onRemove,
void Function(int insertID)? onInsert, void Function(int insertID)? onInsert,
void Function()? onUpdate, void Function()? onUpdate,
void Function(int count)? onHistoryReceived,
}) async { }) async {
await postLoad(); await postLoad();
var events; var events;
@ -1166,6 +1170,7 @@ class Room {
onRemove: onRemove, onRemove: onRemove,
onInsert: onInsert, onInsert: onInsert,
onUpdate: onUpdate, onUpdate: onUpdate,
onHistoryReceived: onHistoryReceived,
); );
if (client.database == null) { if (client.database == null) {
await requestHistory(historyCount: 10); await requestHistory(historyCount: 10);

View File

@ -36,6 +36,7 @@ class Timeline {
final void Function(int index)? onChange; final void Function(int index)? onChange;
final void Function(int index)? onInsert; final void Function(int index)? onInsert;
final void Function(int index)? onRemove; final void Function(int index)? onRemove;
final void Function(int count)? onHistoryReceived;
StreamSubscription<EventUpdate>? sub; StreamSubscription<EventUpdate>? sub;
StreamSubscription<SyncUpdate>? roomSub; StreamSubscription<SyncUpdate>? roomSub;
@ -86,19 +87,16 @@ class Timeline {
); );
if (eventsFromStore != null && eventsFromStore.isNotEmpty) { if (eventsFromStore != null && eventsFromStore.isNotEmpty) {
events.addAll(eventsFromStore); events.addAll(eventsFromStore);
final startIndex = events.length - eventsFromStore.length; onHistoryReceived?.call(eventsFromStore.length);
final endIndex = events.length;
for (var i = startIndex; i < endIndex; i++) {
onInsert?.call(i);
}
} else { } else {
Logs().v('No more events found in the store. Request from server...'); Logs().v('No more events found in the store. Request from server...');
await room.requestHistory( final count = await room.requestHistory(
historyCount: historyCount, historyCount: historyCount,
onHistoryReceived: () { onHistoryReceived: () {
_collectHistoryUpdates = true; _collectHistoryUpdates = true;
}, },
); );
onHistoryReceived?.call(count);
} }
} finally { } finally {
_collectHistoryUpdates = false; _collectHistoryUpdates = false;
@ -114,6 +112,7 @@ class Timeline {
this.onChange, this.onChange,
this.onInsert, this.onInsert,
this.onRemove, this.onRemove,
this.onHistoryReceived,
}) : events = events ?? [] { }) : events = events ?? [] {
sub = room.client.onEvent.stream.listen(_handleEventUpdate); sub = room.client.onEvent.stream.listen(_handleEventUpdate);
@ -331,11 +330,10 @@ class Timeline {
} else { } else {
index = events.firstIndexWhereNotError; index = events.firstIndexWhereNotError;
events.insert(index, newEvent); events.insert(index, newEvent);
onInsert?.call(index);
} }
addAggregatedEvent(newEvent); addAggregatedEvent(newEvent);
onInsert?.call(index);
} }
} }
if (update && !_collectHistoryUpdates) { if (update && !_collectHistoryUpdates) {

View File

@ -1,6 +1,6 @@
name: matrix name: matrix
description: Matrix Dart SDK description: Matrix Dart SDK
version: 0.8.0 version: 0.8.1
homepage: https://famedly.com homepage: https://famedly.com
repository: https://gitlab.com/famedly/company/frontend/famedlysdk.git repository: https://gitlab.com/famedly/company/frontend/famedlysdk.git

View File

@ -31,6 +31,7 @@ void main() {
final insertList = <int>[]; final insertList = <int>[];
final changeList = <int>[]; final changeList = <int>[];
final removeList = <int>[]; final removeList = <int>[];
final historyRequestList = <int>[];
var olmEnabled = true; var olmEnabled = true;
late Client client; late Client client;
@ -59,6 +60,7 @@ void main() {
onInsert: insertList.add, onInsert: insertList.add,
onChange: changeList.add, onChange: changeList.add,
onRemove: removeList.add, onRemove: removeList.add,
onHistoryReceived: historyRequestList.add,
); );
}); });
@ -291,7 +293,8 @@ void main() {
await Future.delayed(Duration(milliseconds: 50)); await Future.delayed(Duration(milliseconds: 50));
expect(updateCount, 20); expect(updateCount, 20);
expect(insertList, [0, 0, 0, 0, 0, 1, 2, 0, 0, 1, 2]); expect(insertList, [0, 0, 0, 0, 0, 1, 2, 0]);
expect(historyRequestList, []);
expect(timeline.events.length, 3); expect(timeline.events.length, 3);
expect(timeline.events[0].eventId, '3143273582443PhrSn:example.org'); expect(timeline.events[0].eventId, '3143273582443PhrSn:example.org');
expect(timeline.events[1].eventId, '2143273582443PhrSn:example.org'); expect(timeline.events[1].eventId, '2143273582443PhrSn:example.org');