diff --git a/wien_talks/wien_talks_client/lib/src/protocol/client.dart b/wien_talks/wien_talks_client/lib/src/protocol/client.dart index 431e580..80b8565 100644 --- a/wien_talks/wien_talks_client/lib/src/protocol/client.dart +++ b/wien_talks/wien_talks_client/lib/src/protocol/client.dart @@ -47,12 +47,6 @@ class EndpointQuote extends _i1.EndpointRef { @override String get name => 'quote'; - _i2.Future dbPing() => caller.callServerEndpoint( - '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} diff --git a/wien_talks/wien_talks_flutter/devtools_options.yaml b/wien_talks/wien_talks_flutter/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/wien_talks/wien_talks_flutter/devtools_options.yaml @@ -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: diff --git a/wien_talks/wien_talks_flutter/lib/helper/funmap_mgr.dart b/wien_talks/wien_talks_flutter/lib/helper/funmap_mgr.dart index 426d5f7..846e1ad 100644 --- a/wien_talks/wien_talks_flutter/lib/helper/funmap_mgr.dart +++ b/wien_talks/wien_talks_flutter/lib/helper/funmap_mgr.dart @@ -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(); diff --git a/wien_talks/wien_talks_flutter/lib/helper/time_util.dart b/wien_talks/wien_talks_flutter/lib/helper/time_util.dart new file mode 100644 index 0000000..e7e8c68 --- /dev/null +++ b/wien_talks/wien_talks_flutter/lib/helper/time_util.dart @@ -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')}', + }; +} diff --git a/wien_talks/wien_talks_flutter/lib/screens/home_screen.dart b/wien_talks/wien_talks_flutter/lib/screens/home_screen.dart index e5de7a2..7b56d8d 100644 --- a/wien_talks/wien_talks_flutter/lib/screens/home_screen.dart +++ b/wien_talks/wien_talks_flutter/lib/screens/home_screen.dart @@ -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, ), diff --git a/wien_talks/wien_talks_flutter/lib/screens/news_screen.dart b/wien_talks/wien_talks_flutter/lib/screens/news_screen.dart index 3701beb..4ec3a2a 100644 --- a/wien_talks/wien_talks_flutter/lib/screens/news_screen.dart +++ b/wien_talks/wien_talks_flutter/lib/screens/news_screen.dart @@ -17,7 +17,7 @@ class NewsScreen extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ HeadingText(text: "What's being said"), - ShowLatestNewsWidget(), + LatestQuotesScreen(), SizedBox( height: 30, ), diff --git a/wien_talks/wien_talks_flutter/lib/screens/show_latest_news_widget.dart b/wien_talks/wien_talks_flutter/lib/screens/show_latest_news_widget.dart index 14fbb22..ec8db6d 100644 --- a/wien_talks/wien_talks_flutter/lib/screens/show_latest_news_widget.dart +++ b/wien_talks/wien_talks_flutter/lib/screens/show_latest_news_widget.dart @@ -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 createState() => _ShowLatestNewsWidgetState(); + State createState() => _LatestQuotesScreenState(); } -class _ShowLatestNewsWidgetState extends State { - List? _quotes; +class _LatestQuotesScreenState extends State { + final List _quotes = []; + StreamSubscription? _sub; + Object? _error; - bool _loading = true; @override void initState() { super.initState(); - _fetch(); + _connectStream(); } - Future _fetch() async { - setState(() { - _loading = true; - _error = null; - }); - try { - final list = await FunmapMgr().client.quote.getAllQuotes(); - final quotes = list.whereType().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 _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 _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.from(_quotes!); - copy[idx] = updated; - _quotes = copy; + _quotes[idx] = updated; + _sortDesc(); }); try { await FunmapMgr().client.quote.updateQuote(updated); } catch (e) { - setState(() { - final copy = List.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 { ), ); } - final quotes = _quotes ?? const []; - 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 { 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 { }, ); - if (unboundedHeight) return list; - return RefreshIndicator(onRefresh: _refresh, child: list); + return unboundedHeight + ? list + : RefreshIndicator(onRefresh: () async {}, child: list); }, ); } diff --git a/wien_talks/wien_talks_flutter/lib/widgets/add_quote_fab.dart b/wien_talks/wien_talks_flutter/lib/widgets/add_quote_fab.dart index 28af240..848344f 100644 --- a/wien_talks/wien_talks_flutter/lib/widgets/add_quote_fab.dart +++ b/wien_talks/wien_talks_flutter/lib/widgets/add_quote_fab.dart @@ -125,7 +125,7 @@ Future 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: diff --git a/wien_talks/wien_talks_flutter/lib/widgets/screen_widget.dart b/wien_talks/wien_talks_flutter/lib/widgets/screen_widget.dart index d24fbc5..981a777 100644 --- a/wien_talks/wien_talks_flutter/lib/widgets/screen_widget.dart +++ b/wien_talks/wien_talks_flutter/lib/widgets/screen_widget.dart @@ -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), diff --git a/wien_talks/wien_talks_server/lib/src/generated/endpoints.dart b/wien_talks/wien_talks_server/lib/src/generated/endpoints.dart index d7bddaf..9f25b92 100644 --- a/wien_talks/wien_talks_server/lib/src/generated/endpoints.dart +++ b/wien_talks/wien_talks_server/lib/src/generated/endpoints.dart @@ -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 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(), + nullable: false, + ) + }, + streamParams: {}, + returnType: _i1.MethodStreamReturnType.streamType, + call: ( + _i1.Session session, + Map params, + Map streamParams, + ) => + (endpoints['quote'] as _i3.QuoteEndpoint).streamAllQuotes( + session, + limit: params['limit'], + ), + ), }, ); connectors['votes'] = _i1.EndpointConnector( diff --git a/wien_talks/wien_talks_server/lib/src/generated/protocol.yaml b/wien_talks/wien_talks_server/lib/src/generated/protocol.yaml index 1e633c7..1183893 100644 --- a/wien_talks/wien_talks_server/lib/src/generated/protocol.yaml +++ b/wien_talks/wien_talks_server/lib/src/generated/protocol.yaml @@ -2,10 +2,10 @@ health: - ping: - all: quote: - - dbPing: - createQuote: - updateQuote: - getAllQuotes: + - streamAllQuotes: votes: - getAllVotes: - createVote: diff --git a/wien_talks/wien_talks_server/lib/src/quotes/quotes_endpoint.dart b/wien_talks/wien_talks_server/lib/src/quotes/quotes_endpoint.dart index b86f3eb..fd501e2 100644 --- a/wien_talks/wien_talks_server/lib/src/quotes/quotes_endpoint.dart +++ b/wien_talks/wien_talks_server/lib/src/quotes/quotes_endpoint.dart @@ -8,13 +8,6 @@ import 'package:wien_talks_server/src/quotes/quote_util.dart'; class QuoteEndpoint extends Endpoint { static const _channelQuoteUpdates = 'quote-updates'; - Future 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 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> streamAllQuotes(StreamingSession session, - {int limit = 200}) async { - if (limit <= 0 || limit > 500) limit = 200; - + Stream streamAllQuotes(Session session, {int limit = 50}) { final controller = StreamController(); final live = session.messages.createStream(_channelQuoteUpdates); - final liveSub = live.listen( + + StreamSubscription? 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.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; } diff --git a/wien_talks/wien_talks_server/test/integration/test_tools/serverpod_test_tools.dart b/wien_talks/wien_talks_server/test/integration/test_tools/serverpod_test_tools.dart index d4fe81d..a54a450 100644 --- a/wien_talks/wien_talks_server/test/integration/test_tools/serverpod_test_tools.dart +++ b/wien_talks/wien_talks_server/test/integration/test_tools/serverpod_test_tools.dart @@ -210,32 +210,6 @@ class _QuoteEndpoint { final _i2.SerializationManager _serializationManager; - _i3.Future 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); - 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 {