improve quote list performance

This commit is contained in:
tk 2025-08-17 02:28:19 +02:00
parent bf28ff429a
commit 47cfb949ac
13 changed files with 151 additions and 132 deletions

View file

@ -47,12 +47,6 @@ class EndpointQuote extends _i1.EndpointRef {
@override @override
String get name => 'quote'; String get name => 'quote';
_i2.Future<String> dbPing() => caller.callServerEndpoint<String>(
'quote',
'dbPing',
{},
);
_i2.Future<_i4.Quote> createQuote(_i5.CreateQuoteRequest req) => _i2.Future<_i4.Quote> createQuote(_i5.CreateQuoteRequest req) =>
caller.callServerEndpoint<_i4.Quote>( caller.callServerEndpoint<_i4.Quote>(
'quote', 'quote',
@ -73,6 +67,14 @@ class EndpointQuote extends _i1.EndpointRef {
'getAllQuotes', 'getAllQuotes',
{}, {},
); );
_i2.Stream<_i4.Quote> streamAllQuotes({required int limit}) =>
caller.callStreamingServerEndpoint<_i2.Stream<_i4.Quote>, _i4.Quote>(
'quote',
'streamAllQuotes',
{'limit': limit},
{},
);
} }
/// {@category Endpoint} /// {@category Endpoint}

View file

@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:

View file

@ -29,7 +29,7 @@ class FunmapMgr {
serverUrl = serverUrl =
serverUrlFromEnv.isEmpty ? 'http://$localhost:8080/' : serverUrlFromEnv; serverUrlFromEnv.isEmpty ? 'http://$localhost:8080/' : serverUrlFromEnv;
client = Client(serverUrl, connectionTimeout: const Duration(seconds: 5)) client = Client(serverUrl, connectionTimeout: const Duration(seconds: 2))
..connectivityMonitor = FlutterConnectivityMonitor(); ..connectivityMonitor = FlutterConnectivityMonitor();
client.openStreamingConnection(); client.openStreamingConnection();

View file

@ -0,0 +1,17 @@
// Clanker code, I caved
String timeAgo(DateTime? dt) {
final d =
(dt ?? DateTime.fromMillisecondsSinceEpoch(0, isUtc: true)).toLocal();
final now = DateTime.now();
final diff = now.isBefore(d) ? Duration.zero : now.difference(d);
return switch (diff.inSeconds) {
< 60 => 'just now',
< 3600 => '${diff.inMinutes}m ago',
< 86400 => '${diff.inHours}h ago',
< 604800 => '${diff.inDays}d ago',
_ => '${d.year.toString().padLeft(4, '0')}-'
'${d.month.toString().padLeft(2, '0')}-'
'${d.day.toString().padLeft(2, '0')}',
};
}

View file

@ -20,7 +20,7 @@ class HomeScreen extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
IntroTextWidget(), IntroTextWidget(),
SizedBox(height: 200, child: ShowLatestNewsWidget()), SizedBox(height: 200, child: LatestQuotesScreen()),
SizedBox( SizedBox(
height: 30, height: 30,
), ),

View file

@ -17,7 +17,7 @@ class NewsScreen extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
HeadingText(text: "What's being said"), HeadingText(text: "What's being said"),
ShowLatestNewsWidget(), LatestQuotesScreen(),
SizedBox( SizedBox(
height: 30, height: 30,
), ),

View file

@ -3,97 +3,91 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:wien_talks_client/wien_talks_client.dart'; import 'package:wien_talks_client/wien_talks_client.dart';
import 'package:wien_talks_flutter/helper/funmap_mgr.dart'; import 'package:wien_talks_flutter/helper/funmap_mgr.dart';
import 'package:wien_talks_flutter/helper/time_util.dart';
import 'package:wien_talks_flutter/widgets/quote_card.dart'; import 'package:wien_talks_flutter/widgets/quote_card.dart';
class ShowLatestNewsWidget extends StatefulWidget { class LatestQuotesScreen extends StatefulWidget {
const ShowLatestNewsWidget({super.key}); const LatestQuotesScreen({super.key});
@override @override
State<ShowLatestNewsWidget> createState() => _ShowLatestNewsWidgetState(); State<LatestQuotesScreen> createState() => _LatestQuotesScreenState();
} }
class _ShowLatestNewsWidgetState extends State<ShowLatestNewsWidget> { class _LatestQuotesScreenState extends State<LatestQuotesScreen> {
List<Quote>? _quotes; final List<Quote> _quotes = [];
StreamSubscription<Quote>? _sub;
Object? _error; Object? _error;
bool _loading = true;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_fetch(); _connectStream();
} }
Future<void> _fetch() async { @override
setState(() { void dispose() {
_loading = true; _sub?.cancel();
_error = null; super.dispose();
});
try {
final list = await FunmapMgr().client.quote.getAllQuotes();
final quotes = list.whereType<Quote>().toList(growable: false);
quotes.sort((a, b) => (b.createdAt).compareTo(a.createdAt));
setState(() {
_quotes = quotes;
});
} catch (e) {
setState(() => _error = e);
} finally {
if (mounted) setState(() => _loading = false);
}
} }
Future<void> _refresh() => _fetch(); void _connectStream() {
_sub?.cancel();
_sub = FunmapMgr().client.quote.streamAllQuotes(limit: 50).listen(
(q) => setState(() => _upsert(q)),
onError: (e) => setState(() => _error = e),
onDone: () => Future.delayed(const Duration(seconds: 2), () {
if (mounted) _connectStream();
}),
cancelOnError: false,
);
}
void _upsert(Quote q) {
final i = _quotes.indexWhere((x) => x.id == q.id);
if (i >= 0)
_quotes[i] = q;
else
_quotes.add(q);
_quotes.sort((a, b) => b.createdAt.compareTo(a.createdAt));
}
void _sortDesc() {
_quotes.sort((a, b) => b.createdAt.compareTo(a.createdAt));
}
Future<void> _vote(Quote quote, bool up) async { Future<void> _vote(Quote quote, bool up) async {
if (_quotes == null) return; final idx = _quotes.indexWhere((q) => q.id == quote.id);
final idx = _quotes!.indexWhere((q) => q.id == quote.id);
if (idx < 0) return; if (idx < 0) return;
final original = _quotes![idx]; final original = _quotes[idx];
final updated = original.copyWith( final updated = original.copyWith(
upvotes: up ? original.upvotes + 1 : original.upvotes, upvotes: up ? original.upvotes + 1 : original.upvotes,
downvotes: up ? original.downvotes : original.downvotes + 1, downvotes: up ? original.downvotes : original.downvotes + 1,
); );
setState(() { setState(() {
final copy = List<Quote>.from(_quotes!); _quotes[idx] = updated;
copy[idx] = updated; _sortDesc();
_quotes = copy;
}); });
try { try {
await FunmapMgr().client.quote.updateQuote(updated); await FunmapMgr().client.quote.updateQuote(updated);
} catch (e) { } catch (e) {
setState(() {
final copy = List<Quote>.from(_quotes!);
copy[idx] = original;
_quotes = copy;
});
if (!mounted) return; if (!mounted) return;
setState(() => _quotes[idx] = original);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Vote failed: $e')), SnackBar(content: Text('Vote failed: $e')),
); );
} }
} }
String _timeAgo(DateTime? dt) {
final d = (dt ?? DateTime.fromMillisecondsSinceEpoch(0)).toLocal();
final diff = DateTime.now().difference(d);
if (diff.inSeconds < 60) return 'just now';
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
if (diff.inHours < 24) return '${diff.inHours}h ago';
if (diff.inDays < 7) return '${diff.inDays}d ago';
final m = d.month.toString().padLeft(2, '0');
final day = d.day.toString().padLeft(2, '0');
return '${d.year}-$m-$day';
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (_loading) { if (_quotes.isEmpty && _error == null) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
if (_error != null) { if (_error != null && _quotes.isEmpty) {
return Center( return Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@ -101,9 +95,8 @@ class _ShowLatestNewsWidgetState extends State<ShowLatestNewsWidget> {
), ),
); );
} }
final quotes = _quotes ?? const <Quote>[]; if (_quotes.isEmpty) {
if (quotes.isEmpty) { return const Center(child: Text('Nix da. Sag halt was'));
return const Center(child: Text('No quotes yet.'));
} }
return LayoutBuilder( return LayoutBuilder(
@ -116,14 +109,14 @@ class _ShowLatestNewsWidgetState extends State<ShowLatestNewsWidget> {
physics: unboundedHeight physics: unboundedHeight
? const NeverScrollableScrollPhysics() ? const NeverScrollableScrollPhysics()
: const AlwaysScrollableScrollPhysics(), : const AlwaysScrollableScrollPhysics(),
itemCount: quotes.length, itemCount: _quotes.length,
separatorBuilder: (_, __) => const SizedBox(height: 6), separatorBuilder: (_, __) => const SizedBox(height: 6),
itemBuilder: (context, i) { itemBuilder: (context, i) {
final q = quotes[i]; final q = _quotes[i];
final author = (q.authorName ?? '').trim(); final author = (q.authorName ?? '').trim();
final meta = [ final meta = [
if (author.isNotEmpty) author, if (author.isNotEmpty) author,
_timeAgo(q.createdAt), timeAgo(q.createdAt),
].join(' · '); ].join(' · ');
return QuoteCard( return QuoteCard(
@ -135,8 +128,9 @@ class _ShowLatestNewsWidgetState extends State<ShowLatestNewsWidget> {
}, },
); );
if (unboundedHeight) return list; return unboundedHeight
return RefreshIndicator(onRefresh: _refresh, child: list); ? list
: RefreshIndicator(onRefresh: () async {}, child: list);
}, },
); );
} }

View file

@ -125,7 +125,7 @@ Future<QuoteDraft?> showQuoteEditor(
minLines: 3, minLines: 3,
textInputAction: TextInputAction.newline, textInputAction: TextInputAction.newline,
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'Quote text', labelText: 'Vienna`s finest',
hintText: 'How is Vienna surprising you today', hintText: 'How is Vienna surprising you today',
helperText: 'Max $maxChars characters', helperText: 'Max $maxChars characters',
counterText: counterText:

View file

@ -16,16 +16,7 @@ class ScreenWidget extends StatelessWidget {
appBar: AppBar( appBar: AppBar(
title: const Text('FunMap'), title: const Text('FunMap'),
), ),
floatingActionButton: AddQuoteFab( floatingActionButton: AddQuoteFab(),
// onSubmit: (draft) {
// FunmapMgr().client.quote.createQuote(CreateQuoteRequest(
// text: draft.text,
// authorName: draft.author,
// lat: LocationMgr().lastLocation!.latitude!,
// lng: LocationMgr().lastLocation!.longitude!,
// ));
// },
),
body: SafeArea( body: SafeArea(
child: Padding( child: Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),

View file

@ -77,15 +77,6 @@ class Endpoints extends _i1.EndpointDispatch {
name: 'quote', name: 'quote',
endpoint: endpoints['quote']!, endpoint: endpoints['quote']!,
methodConnectors: { methodConnectors: {
'dbPing': _i1.MethodConnector(
name: 'dbPing',
params: {},
call: (
_i1.Session session,
Map<String, dynamic> params,
) async =>
(endpoints['quote'] as _i3.QuoteEndpoint).dbPing(session),
),
'createQuote': _i1.MethodConnector( 'createQuote': _i1.MethodConnector(
name: 'createQuote', name: 'createQuote',
params: { params: {
@ -131,6 +122,27 @@ class Endpoints extends _i1.EndpointDispatch {
) async => ) async =>
(endpoints['quote'] as _i3.QuoteEndpoint).getAllQuotes(session), (endpoints['quote'] as _i3.QuoteEndpoint).getAllQuotes(session),
), ),
'streamAllQuotes': _i1.MethodStreamConnector(
name: 'streamAllQuotes',
params: {
'limit': _i1.ParameterDescription(
name: 'limit',
type: _i1.getType<int>(),
nullable: false,
)
},
streamParams: {},
returnType: _i1.MethodStreamReturnType.streamType,
call: (
_i1.Session session,
Map<String, dynamic> params,
Map<String, Stream> streamParams,
) =>
(endpoints['quote'] as _i3.QuoteEndpoint).streamAllQuotes(
session,
limit: params['limit'],
),
),
}, },
); );
connectors['votes'] = _i1.EndpointConnector( connectors['votes'] = _i1.EndpointConnector(

View file

@ -2,10 +2,10 @@ health:
- ping: - ping:
- all: - all:
quote: quote:
- dbPing:
- createQuote: - createQuote:
- updateQuote: - updateQuote:
- getAllQuotes: - getAllQuotes:
- streamAllQuotes:
votes: votes:
- getAllVotes: - getAllVotes:
- createVote: - createVote:

View file

@ -8,13 +8,6 @@ import 'package:wien_talks_server/src/quotes/quote_util.dart';
class QuoteEndpoint extends Endpoint { class QuoteEndpoint extends Endpoint {
static const _channelQuoteUpdates = 'quote-updates'; static const _channelQuoteUpdates = 'quote-updates';
Future<String> dbPing(Session session) async {
await session.db.unsafeQuery('SELECT 1;'); // connectivity
await session.db
.unsafeQuery('SELECT 1 FROM public.quote LIMIT 1;'); // table visible
return 'ok';
}
Future<Quote> createQuote(Session session, CreateQuoteRequest req) async { Future<Quote> createQuote(Session session, CreateQuoteRequest req) async {
final authInfo = await session.authenticated; final authInfo = await session.authenticated;
final userId = authInfo?.userId; final userId = authInfo?.userId;
@ -50,7 +43,6 @@ class QuoteEndpoint extends Endpoint {
final quoteList = await Quote.db.find( final quoteList = await Quote.db.find(
session, session,
// where: (t) => t.visibility.equals(0),
orderBy: (t) => t.createdAt, orderBy: (t) => t.createdAt,
orderDescending: true, orderDescending: true,
); );
@ -62,13 +54,12 @@ class QuoteEndpoint extends Endpoint {
return quoteList; return quoteList;
} }
Future<Stream<Quote>> streamAllQuotes(StreamingSession session, Stream<Quote> streamAllQuotes(Session session, {int limit = 50}) {
{int limit = 200}) async {
if (limit <= 0 || limit > 500) limit = 200;
final controller = StreamController<Quote>(); final controller = StreamController<Quote>();
final live = session.messages.createStream<Quote>(_channelQuoteUpdates); final live = session.messages.createStream<Quote>(_channelQuoteUpdates);
final liveSub = live.listen(
StreamSubscription<Quote>? sub;
sub = live.listen(
(q) { (q) {
if (q.visibility == 0) controller.add(q); if (q.visibility == 0) controller.add(q);
}, },
@ -76,10 +67,9 @@ class QuoteEndpoint extends Endpoint {
onDone: () { onDone: () {
if (!controller.isClosed) controller.close(); if (!controller.isClosed) controller.close();
}, },
cancelOnError: false,
); );
() async* { () async {
try { try {
final snapshot = await Quote.db.find( final snapshot = await Quote.db.find(
session, session,
@ -89,18 +79,22 @@ class QuoteEndpoint extends Endpoint {
limit: limit, limit: limit,
); );
var i = 0;
for (final q in snapshot.reversed) { for (final q in snapshot.reversed) {
controller.add(q); controller.add(q);
if ((++i % 25) == 0) {
await Future<void>.delayed(Duration.zero);
}
} }
} catch (e, st) { } catch (e, st) {
controller.addError(e, st); controller.addError(e, st);
} }
}(); }();
await session.close().then((_) async { controller.onCancel = () async {
await liveSub.cancel(); await sub?.cancel();
await controller.close(); await controller.close();
}); };
return controller.stream; return controller.stream;
} }

View file

@ -210,32 +210,6 @@ class _QuoteEndpoint {
final _i2.SerializationManager _serializationManager; final _i2.SerializationManager _serializationManager;
_i3.Future<String> dbPing(_i1.TestSessionBuilder sessionBuilder) async {
return _i1.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild(
endpoint: 'quote',
method: 'dbPing',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'quote',
methodName: 'dbPing',
parameters: _i1.testObjectToJson({}),
serializationManager: _serializationManager,
);
var _localReturnValue = await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
) as _i3.Future<String>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
}
});
}
_i3.Future<_i5.Quote> createQuote( _i3.Future<_i5.Quote> createQuote(
_i1.TestSessionBuilder sessionBuilder, _i1.TestSessionBuilder sessionBuilder,
_i6.CreateQuoteRequest req, _i6.CreateQuoteRequest req,
@ -320,6 +294,38 @@ class _QuoteEndpoint {
} }
}); });
} }
_i3.Stream<_i5.Quote> streamAllQuotes(
_i1.TestSessionBuilder sessionBuilder, {
required int limit,
}) {
var _localTestStreamManager = _i1.TestStreamManager<_i5.Quote>();
_i1.callStreamFunctionAndHandleExceptions(
() async {
var _localUniqueSession =
(sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild(
endpoint: 'quote',
method: 'streamAllQuotes',
);
var _localCallContext =
await _endpointDispatch.getMethodStreamCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'quote',
methodName: 'streamAllQuotes',
arguments: {'limit': limit},
requestedInputStreams: [],
serializationManager: _serializationManager,
);
await _localTestStreamManager.callStreamMethod(
_localCallContext,
_localUniqueSession,
{},
);
},
_localTestStreamManager.outputStreamController,
);
return _localTestStreamManager.outputStreamController.stream;
}
} }
class _VotesEndpoint { class _VotesEndpoint {