Compare commits

..

No commits in common. "47cfb949ac1c7d157531147e90da06ad67a9247d" and "39e4f1142fef2195126723de60d0b0c75e47605c" have entirely different histories.

13 changed files with 130 additions and 345 deletions

View file

@ -47,6 +47,12 @@ 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',
@ -67,14 +73,6 @@ 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

@ -1,3 +0,0 @@
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: 2))
client = Client(serverUrl, connectionTimeout: const Duration(seconds: 5))
..connectivityMonitor = FlutterConnectivityMonitor();
client.openStreamingConnection();

View file

@ -1,17 +0,0 @@
// 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: LatestQuotesScreen()),
SizedBox(height: 200, child: ShowLatestNewsWidget()),
SizedBox(
height: 30,
),

View file

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

View file

@ -3,91 +3,97 @@ 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 LatestQuotesScreen extends StatefulWidget {
const LatestQuotesScreen({super.key});
class ShowLatestNewsWidget extends StatefulWidget {
const ShowLatestNewsWidget({super.key});
@override
State<LatestQuotesScreen> createState() => _LatestQuotesScreenState();
State<ShowLatestNewsWidget> createState() => _ShowLatestNewsWidgetState();
}
class _LatestQuotesScreenState extends State<LatestQuotesScreen> {
final List<Quote> _quotes = [];
StreamSubscription<Quote>? _sub;
class _ShowLatestNewsWidgetState extends State<ShowLatestNewsWidget> {
List<Quote>? _quotes;
Object? _error;
bool _loading = true;
@override
void initState() {
super.initState();
_connectStream();
_fetch();
}
@override
void dispose() {
_sub?.cancel();
super.dispose();
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);
}
}
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> _refresh() => _fetch();
Future<void> _vote(Quote quote, bool up) async {
final idx = _quotes.indexWhere((q) => q.id == quote.id);
if (_quotes == null) return;
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(() {
_quotes[idx] = updated;
_sortDesc();
final copy = List<Quote>.from(_quotes!);
copy[idx] = updated;
_quotes = copy;
});
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 (_quotes.isEmpty && _error == null) {
if (_loading) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null && _quotes.isEmpty) {
if (_error != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(16),
@ -95,8 +101,9 @@ class _LatestQuotesScreenState extends State<LatestQuotesScreen> {
),
);
}
if (_quotes.isEmpty) {
return const Center(child: Text('Nix da. Sag halt was'));
final quotes = _quotes ?? const <Quote>[];
if (quotes.isEmpty) {
return const Center(child: Text('No quotes yet.'));
}
return LayoutBuilder(
@ -109,14 +116,14 @@ class _LatestQuotesScreenState extends State<LatestQuotesScreen> {
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(
@ -128,9 +135,8 @@ class _LatestQuotesScreenState extends State<LatestQuotesScreen> {
},
);
return unboundedHeight
? list
: RefreshIndicator(onRefresh: () async {}, child: list);
if (unboundedHeight) return list;
return RefreshIndicator(onRefresh: _refresh, child: list);
},
);
}

View file

@ -1,199 +1,17 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:location/location.dart';
import 'package:wien_talks_client/wien_talks_client.dart';
import 'package:wien_talks_flutter/helper/funmap_mgr.dart';
class QuoteDraft {
final String text;
final String? author;
const QuoteDraft({required this.text, this.author});
}
typedef QuoteSubmit = FutureOr<void> Function(QuoteDraft draft);
/// If Simon reads this I'm sorry
class AddQuoteFab extends StatelessWidget {
const AddQuoteFab({
super.key,
this.tooltip = 'Add quote',
this.icon = Icons.format_quote_rounded,
this.mini = false,
});
final String tooltip;
final IconData icon;
final bool mini;
Future<void> onSubmit(QuoteDraft draft) async {
final fix = await _getHackyLocation();
if (fix == null || fix.latitude == null) {
return;
}
await FunmapMgr().client.quote.createQuote(
CreateQuoteRequest(
text: draft.text,
authorName: draft.author,
lat: fix.latitude!,
lng: fix.longitude!,
),
);
}
Future<LocationData?> _getHackyLocation() async {
final loc = Location();
if (!await loc.serviceEnabled()) {
if (!await loc.requestService()) return null;
}
var perm = await loc.hasPermission();
if (perm == PermissionStatus.denied) {
perm = await loc.requestPermission();
if (perm != PermissionStatus.granted) return null;
}
return await loc.getLocation();
}
@override
Widget build(BuildContext context) {
return FloatingActionButton(
mini: mini,
tooltip: tooltip,
onPressed: () async {
final draft = await showQuoteEditor(context);
if (draft != null) {
await onSubmit(draft);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Quote saved')),
);
}
}
},
child: Icon(icon),
);
return FloatingActionButton(onPressed: () {
FunmapMgr().client.quote.createQuote(
CreateQuoteRequest(text: 'Quote Text', lat: 22, lng: 140));
});
}
}
Future<QuoteDraft?> showQuoteEditor(
BuildContext context, {
String? initialText,
String? initialAuthor,
}) async {
final textCtrl = TextEditingController(text: initialText ?? '');
final authorCtrl = TextEditingController(text: initialAuthor ?? '');
const maxChars = 500;
return showModalBottomSheet<QuoteDraft>(
context: context,
isScrollControlled: true,
useSafeArea: true,
showDragHandle: true,
builder: (ctx) {
bool canSave() {
final t = textCtrl.text.trim();
return t.isNotEmpty && t.length <= maxChars;
}
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(ctx).viewInsets.bottom,
),
child: StatefulBuilder(
builder: (ctx, setSheetState) {
void onChanged(_) => setSheetState(() {});
final remaining = maxChars - textCtrl.text.characters.length;
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Text('New quote', style: Theme.of(ctx).textTheme.titleMedium),
const SizedBox(height: 8),
TextField(
controller: textCtrl,
onChanged: onChanged,
autofocus: true,
maxLines: null,
minLines: 3,
textInputAction: TextInputAction.newline,
decoration: InputDecoration(
labelText: 'Vienna`s finest',
hintText: 'How is Vienna surprising you today',
helperText: 'Max $maxChars characters',
counterText:
'${textCtrl.text.characters.length}/$maxChars',
border: const OutlineInputBorder(),
),
maxLength: maxChars,
maxLengthEnforcement: MaxLengthEnforcement.enforced,
),
const SizedBox(height: 12),
TextField(
controller: authorCtrl,
textInputAction: TextInputAction.done,
decoration: const InputDecoration(
labelText: 'Author (optional)',
hintText: 'e.g., Schmausi Wamperl',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => Navigator.of(ctx).pop(null),
child: const Text('Cancel'),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton.icon(
onPressed: canSave()
? () => Navigator.of(ctx).pop(
QuoteDraft(
text: textCtrl.text.trim(),
author: authorCtrl.text.trim().isEmpty
? null
: authorCtrl.text.trim(),
),
)
: null,
icon: const Icon(Icons.check),
label: const Text('Save'),
),
),
],
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: Text(
remaining >= 0
? '$remaining characters left'
: '${-remaining} over limit',
style: Theme.of(ctx).textTheme.labelSmall?.copyWith(
color: Theme.of(ctx)
.colorScheme
.onSurface
.withValues(alpha: 0.7),
),
),
),
],
),
);
},
),
);
},
);
}

View file

@ -3,7 +3,6 @@ import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_spinkit/flutter_spinkit.dart';
import 'package:loader_overlay/loader_overlay.dart';
import 'package:wien_talks_flutter/widgets/add_quote_fab.dart';
class ScreenWidget extends StatelessWidget {
final Widget child;
@ -16,7 +15,7 @@ class ScreenWidget extends StatelessWidget {
appBar: AppBar(
title: const Text('FunMap'),
),
floatingActionButton: AddQuoteFab(),
// floatingActionButton: AddQuoteFab(),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(8.0),
@ -29,8 +28,7 @@ class ScreenWidget extends StatelessWidget {
);
case 1:
return Center(
child: SpinKitCubeGrid(
size: 50, color: Theme.of(context).primaryColor),
child: SpinKitCubeGrid(size: 50, color: Theme.of(context).primaryColor),
);
case 2:
return Center(
@ -38,18 +36,15 @@ class ScreenWidget extends StatelessWidget {
);
case 3:
return Center(
child:
SpinKitHourGlass(color: Theme.of(context).primaryColor),
child: SpinKitHourGlass(color: Theme.of(context).primaryColor),
);
case 4:
return Center(
child: SpinKitFadingCircle(
color: Theme.of(context).primaryColor),
child: SpinKitFadingCircle(color: Theme.of(context).primaryColor),
);
default:
return Center(
child: SpinKitPulsingGrid(
color: Theme.of(context).primaryColor),
child: SpinKitPulsingGrid(color: Theme.of(context).primaryColor),
);
}
},

View file

@ -77,6 +77,15 @@ 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: {
@ -122,27 +131,6 @@ 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,6 +8,13 @@ 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;
@ -43,6 +50,7 @@ class QuoteEndpoint extends Endpoint {
final quoteList = await Quote.db.find(
session,
// where: (t) => t.visibility.equals(0),
orderBy: (t) => t.createdAt,
orderDescending: true,
);
@ -54,12 +62,13 @@ class QuoteEndpoint extends Endpoint {
return quoteList;
}
Stream<Quote> streamAllQuotes(Session session, {int limit = 50}) {
Future<Stream<Quote>> streamAllQuotes(StreamingSession session,
{int limit = 200}) async {
if (limit <= 0 || limit > 500) limit = 200;
final controller = StreamController<Quote>();
final live = session.messages.createStream<Quote>(_channelQuoteUpdates);
StreamSubscription<Quote>? sub;
sub = live.listen(
final liveSub = live.listen(
(q) {
if (q.visibility == 0) controller.add(q);
},
@ -67,9 +76,10 @@ class QuoteEndpoint extends Endpoint {
onDone: () {
if (!controller.isClosed) controller.close();
},
cancelOnError: false,
);
() async {
() async* {
try {
final snapshot = await Quote.db.find(
session,
@ -79,22 +89,18 @@ 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);
}
}();
controller.onCancel = () async {
await sub?.cancel();
await session.close().then((_) async {
await liveSub.cancel();
await controller.close();
};
});
return controller.stream;
}

View file

@ -210,6 +210,32 @@ 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,
@ -294,38 +320,6 @@ 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 {