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
String get name => 'quote';
_i2.Future<String> dbPing() => caller.callServerEndpoint<String>(
'quote',
'dbPing',
{},
);
_i2.Future<_i4.Quote> createQuote(_i5.CreateQuoteRequest req) =>
caller.callServerEndpoint<_i4.Quote>(
'quote',
@ -73,6 +67,14 @@ class EndpointQuote extends _i1.EndpointRef {
'getAllQuotes',
{},
);
_i2.Stream<_i4.Quote> streamAllQuotes({required int limit}) =>
caller.callStreamingServerEndpoint<_i2.Stream<_i4.Quote>, _i4.Quote>(
'quote',
'streamAllQuotes',
{'limit': limit},
{},
);
}
/// {@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 =
serverUrlFromEnv.isEmpty ? 'http://$localhost:8080/' : serverUrlFromEnv;
client = Client(serverUrl, connectionTimeout: const Duration(seconds: 5))
client = Client(serverUrl, connectionTimeout: const Duration(seconds: 2))
..connectivityMonitor = FlutterConnectivityMonitor();
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,
children: [
IntroTextWidget(),
SizedBox(height: 200, child: ShowLatestNewsWidget()),
SizedBox(height: 200, child: LatestQuotesScreen()),
SizedBox(
height: 30,
),

View file

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

View file

@ -3,97 +3,91 @@ import 'dart:async';
import 'package:flutter/material.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/time_util.dart';
import 'package:wien_talks_flutter/widgets/quote_card.dart';
class ShowLatestNewsWidget extends StatefulWidget {
const ShowLatestNewsWidget({super.key});
class LatestQuotesScreen extends StatefulWidget {
const LatestQuotesScreen({super.key});
@override
State<ShowLatestNewsWidget> createState() => _ShowLatestNewsWidgetState();
State<LatestQuotesScreen> createState() => _LatestQuotesScreenState();
}
class _ShowLatestNewsWidgetState extends State<ShowLatestNewsWidget> {
List<Quote>? _quotes;
class _LatestQuotesScreenState extends State<LatestQuotesScreen> {
final List<Quote> _quotes = [];
StreamSubscription<Quote>? _sub;
Object? _error;
bool _loading = true;
@override
void initState() {
super.initState();
_fetch();
_connectStream();
}
Future<void> _fetch() async {
setState(() {
_loading = true;
_error = null;
});
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);
}
@override
void dispose() {
_sub?.cancel();
super.dispose();
}
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 {
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;
final original = _quotes![idx];
final original = _quotes[idx];
final updated = original.copyWith(
upvotes: up ? original.upvotes + 1 : original.upvotes,
downvotes: up ? original.downvotes : original.downvotes + 1,
);
setState(() {
final copy = List<Quote>.from(_quotes!);
copy[idx] = updated;
_quotes = copy;
_quotes[idx] = updated;
_sortDesc();
});
try {
await FunmapMgr().client.quote.updateQuote(updated);
} catch (e) {
setState(() {
final copy = List<Quote>.from(_quotes!);
copy[idx] = original;
_quotes = copy;
});
if (!mounted) return;
setState(() => _quotes[idx] = original);
ScaffoldMessenger.of(context).showSnackBar(
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
Widget build(BuildContext context) {
if (_loading) {
if (_quotes.isEmpty && _error == null) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null) {
if (_error != null && _quotes.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(16),
@ -101,9 +95,8 @@ class _ShowLatestNewsWidgetState extends State<ShowLatestNewsWidget> {
),
);
}
final quotes = _quotes ?? const <Quote>[];
if (quotes.isEmpty) {
return const Center(child: Text('No quotes yet.'));
if (_quotes.isEmpty) {
return const Center(child: Text('Nix da. Sag halt was'));
}
return LayoutBuilder(
@ -116,14 +109,14 @@ class _ShowLatestNewsWidgetState extends State<ShowLatestNewsWidget> {
physics: unboundedHeight
? const NeverScrollableScrollPhysics()
: const AlwaysScrollableScrollPhysics(),
itemCount: quotes.length,
itemCount: _quotes.length,
separatorBuilder: (_, __) => const SizedBox(height: 6),
itemBuilder: (context, i) {
final q = quotes[i];
final q = _quotes[i];
final author = (q.authorName ?? '').trim();
final meta = [
if (author.isNotEmpty) author,
_timeAgo(q.createdAt),
timeAgo(q.createdAt),
].join(' · ');
return QuoteCard(
@ -135,8 +128,9 @@ class _ShowLatestNewsWidgetState extends State<ShowLatestNewsWidget> {
},
);
if (unboundedHeight) return list;
return RefreshIndicator(onRefresh: _refresh, child: list);
return unboundedHeight
? list
: RefreshIndicator(onRefresh: () async {}, child: list);
},
);
}

View file

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

View file

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

View file

@ -77,15 +77,6 @@ class Endpoints extends _i1.EndpointDispatch {
name: 'quote',
endpoint: endpoints['quote']!,
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(
name: 'createQuote',
params: {
@ -131,6 +122,27 @@ class Endpoints extends _i1.EndpointDispatch {
) async =>
(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(

View file

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

View file

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

View file

@ -210,32 +210,6 @@ class _QuoteEndpoint {
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(
_i1.TestSessionBuilder sessionBuilder,
_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 {