diff --git a/wien_talks/wien_talks_flutter/lib/helper/location_filter.dart b/wien_talks/wien_talks_flutter/lib/helper/location_filter.dart new file mode 100644 index 0000000..f523921 --- /dev/null +++ b/wien_talks/wien_talks_flutter/lib/helper/location_filter.dart @@ -0,0 +1,28 @@ +import 'dart:math' as math; + +class LocationFilter { + final double centerLat; + final double centerLon; + final double radiusMeters; + const LocationFilter({ + required this.centerLat, + required this.centerLon, + required this.radiusMeters, + }); + + bool contains(double lat, double lon) => + _haversineMeters(centerLat, centerLon, lat, lon) <= radiusMeters; + + static double _haversineMeters( + double lat1, double lon1, double lat2, double lon2) { + const R = 6371000.0; + final dLat = (lat2 - lat1) * (math.pi / 180.0); + final dLon = (lon2 - lon1) * (math.pi / 180.0); + final a = math.sin(dLat / 2) * math.sin(dLat / 2) + + math.cos(lat1 * (math.pi / 180.0)) * + math.cos(lat2 * (math.pi / 180.0)) * + math.sin(dLon / 2) * + math.sin(dLon / 2); + return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)); + } +} diff --git a/wien_talks/wien_talks_flutter/lib/helper/location_filter_controller.dart b/wien_talks/wien_talks_flutter/lib/helper/location_filter_controller.dart new file mode 100644 index 0000000..f188986 --- /dev/null +++ b/wien_talks/wien_talks_flutter/lib/helper/location_filter_controller.dart @@ -0,0 +1,12 @@ +import 'package:flutter/foundation.dart'; + +import 'location_filter.dart'; + +class FilterController extends ValueNotifier { + FilterController([super.initial]); + void clear() => value = null; + void setLocation(double lat, double lon, double radiusMeters) { + value = LocationFilter( + centerLat: lat, centerLon: lon, radiusMeters: radiusMeters); + } +} diff --git a/wien_talks/wien_talks_flutter/lib/screens/create_event_screen.dart b/wien_talks/wien_talks_flutter/lib/screens/create_event_screen.dart index d31166f..441809b 100644 --- a/wien_talks/wien_talks_flutter/lib/screens/create_event_screen.dart +++ b/wien_talks/wien_talks_flutter/lib/screens/create_event_screen.dart @@ -3,7 +3,7 @@ import 'package:location/location.dart'; import 'package:wien_talks_client/wien_talks_client.dart'; import 'package:wien_talks_flutter/widgets/get_location_widget.dart'; import 'package:wien_talks_flutter/helper/funmap_mgr.dart'; -import 'package:wien_talks_flutter/mapfile_widget.dart'; +import 'package:wien_talks_flutter/widgets/mapfile_widget.dart'; import 'package:wien_talks_flutter/widgets/news_input_form.dart'; import 'package:wien_talks_flutter/widgets/screen_widget.dart'; 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 7b56d8d..86357e0 100644 --- a/wien_talks/wien_talks_flutter/lib/screens/home_screen.dart +++ b/wien_talks/wien_talks_flutter/lib/screens/home_screen.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:wien_talks_flutter/helper/funmap_mgr.dart'; -import 'package:wien_talks_flutter/screens/show_latest_news_widget.dart'; +import 'package:wien_talks_flutter/screens/latest_quotes_screen.dart'; import 'package:wien_talks_flutter/widgets/intro_text_widget.dart'; import 'package:wien_talks_flutter/widgets/screen_widget.dart'; diff --git a/wien_talks/wien_talks_flutter/lib/screens/latest_quotes_screen.dart b/wien_talks/wien_talks_flutter/lib/screens/latest_quotes_screen.dart new file mode 100644 index 0000000..ce9a6e2 --- /dev/null +++ b/wien_talks/wien_talks_flutter/lib/screens/latest_quotes_screen.dart @@ -0,0 +1,221 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.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/location_filter.dart'; +import 'package:wien_talks_flutter/helper/location_util.dart'; +import 'package:wien_talks_flutter/helper/time_util.dart'; +import 'package:wien_talks_flutter/widgets/filter_chips_bar.dart'; +import 'package:wien_talks_flutter/widgets/filter_overlay.dart'; +import 'package:wien_talks_flutter/widgets/flamboyant_quote_card.dart'; + +class LatestQuotesScreen extends StatefulWidget { + const LatestQuotesScreen({super.key}); + + @override + State createState() => _LatestQuotesScreenState(); +} + +class _LatestQuotesScreenState extends State { + final List _quotes = []; + StreamSubscription? _sub; + LocationFilter? _locationFilter; + String _sort = 'new'; + bool _today = false; + bool _nearby = false; + Object? _error; + + @override + void initState() { + super.initState(); + _connectStream(); + } + + @override + void dispose() { + _sub?.cancel(); + super.dispose(); + } + + 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); + } + _applyFilters(); + } + + Future _vote(Quote quote, bool up) async { + final idx = _quotes.indexWhere((q) => q.id == quote.id); + if (idx < 0) return; + + 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; + }); + + try { + await FunmapMgr().client.quote.updateQuote(updated); + } catch (e) { + if (!mounted) return; + setState(() => _quotes[idx] = original); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Vote failed: $e')), + ); + } + } + + List get _view { + final now = DateTime.now(); + + bool isToday(DateTime t) { + final lt = t.toLocal(); + final ln = now.toLocal(); + return lt.year == ln.year && lt.month == ln.month && lt.day == ln.day; + } + + Iterable it = _quotes; + + if (_today) { + it = it.where((q) => isToday(q.createdAt)); + } + if (_nearby && _locationFilter != null) { + final f = _locationFilter!; + it = it.where((q) => f.contains(q.lat, q.long)); + } + + final list = it.toList() + ..sort((a, b) { + if (_sort == 'top') { + final as = (a.upvotes - a.downvotes); + final bs = (b.upvotes - b.downvotes); + final cmp = bs.compareTo(as); + if (cmp != 0) return cmp; + } + return b.createdAt.compareTo(a.createdAt); + }); + + return list; + } + + Future _pickLocationFilter() async { + final picked = await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => LocationFilterSheet(current: _locationFilter), + ); + if (picked != null) { + setState(() { + _locationFilter = picked; + _nearby = true; + }); + _applyFilters(); + } + } + + void _applyFilters() => setState(() {}); + + void _clearFilters() { + setState(() { + _today = false; + _nearby = false; + _locationFilter = null; + }); + _applyFilters(); + } + + @override + Widget build(BuildContext context) { + if (_quotes.isEmpty && _error == null) { + return const Center(child: CircularProgressIndicator()); + } + if (_error != null && _quotes.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Text('Error: $_error'), + ), + ); + } + if (_quotes.isEmpty) { + return const Center(child: Text('Nix da. Sag halt was')); + } + return CustomScrollView( + slivers: [ + SliverAppBar( + automaticallyImplyLeading: false, + pinned: false, + floating: false, + toolbarHeight: 56, + titleSpacing: 12, + title: FilterChipsBar( + sort: _sort, + today: _today, + nearby: _nearby, + onSortChanged: (s) { + setState(() => _sort = s); + _applyFilters(); + }, + onTodayChanged: (v) { + setState(() => _today = v); + _applyFilters(); + }, + onNearbyPressed: _pickLocationFilter, + onClear: (_today || _nearby) ? _clearFilters : null, + ), + elevation: 4, + ), + SliverPadding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), + sliver: SliverMasonryGrid.count( + crossAxisCount: 2, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + childCount: _view.length, + itemBuilder: (context, i) { + final q = _view[i]; + final author = (q.authorName ?? '').trim(); + final meta = [ + if (author.isNotEmpty) author, + timeAgo(q.createdAt), + ].join(' · '); + return FlamboyantQuoteCard( + quote: q, + meta: meta, + onVoteUp: () async { + await _vote(q, true); + _applyFilters(); + }, + onVoteDown: () async { + await _vote(q, false); + _applyFilters(); + }, + staticMapUrlBuilder: gStaticMap, + ); + }, + )), + ], + ); + } +} 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 79d2c14..5a7ea23 100644 --- a/wien_talks/wien_talks_flutter/lib/screens/news_screen.dart +++ b/wien_talks/wien_talks_flutter/lib/screens/news_screen.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -import 'package:wien_talks_flutter/screens/show_latest_news_widget.dart'; +import 'package:wien_talks_flutter/screens/latest_quotes_screen.dart'; import 'package:wien_talks_flutter/widgets/heading_text.dart'; import 'package:wien_talks_flutter/widgets/screen_widget.dart'; 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 deleted file mode 100644 index af1d361..0000000 --- a/wien_talks/wien_talks_flutter/lib/screens/show_latest_news_widget.dart +++ /dev/null @@ -1,133 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.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/location_util.dart'; -import 'package:wien_talks_flutter/helper/time_util.dart'; -import 'package:wien_talks_flutter/widgets/flamboyant_quote_card.dart'; - -class LatestQuotesScreen extends StatefulWidget { - const LatestQuotesScreen({super.key}); - - @override - State createState() => _LatestQuotesScreenState(); -} - -class _LatestQuotesScreenState extends State { - final List _quotes = []; - StreamSubscription? _sub; - - Object? _error; - - @override - void initState() { - super.initState(); - _connectStream(); - } - - @override - void dispose() { - _sub?.cancel(); - super.dispose(); - } - - 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 { - final idx = _quotes.indexWhere((q) => q.id == quote.id); - if (idx < 0) return; - - 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(); - }); - - try { - await FunmapMgr().client.quote.updateQuote(updated); - } catch (e) { - if (!mounted) return; - setState(() => _quotes[idx] = original); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Vote failed: $e')), - ); - } - } - - @override - Widget build(BuildContext context) { - if (_quotes.isEmpty && _error == null) { - return const Center(child: CircularProgressIndicator()); - } - if (_error != null && _quotes.isEmpty) { - return Center( - child: Padding( - padding: const EdgeInsets.all(16), - child: Text('Error: $_error'), - ), - ); - } - if (_quotes.isEmpty) { - return const Center(child: Text('Nix da. Sag halt was')); - } - - return LayoutBuilder( - builder: (context, constraints) { - return MasonryGridView.count( - cacheExtent: 20, - crossAxisCount: 2, - mainAxisSpacing: 8, - crossAxisSpacing: 8, - padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), - itemCount: _quotes.length, - itemBuilder: (context, i) { - final q = _quotes[i]; - final author = (q.authorName ?? '').trim(); - final meta = [ - if (author.isNotEmpty) author, - timeAgo(q.createdAt), - ].join(' · '); - - return FlamboyantQuoteCard( - quote: q, - meta: meta, - onVoteUp: () => _vote(q, true), - onVoteDown: () => _vote(q, false), - staticMapUrlBuilder: gStaticMap); - }, - ); - }, - ); - } -} diff --git a/wien_talks/wien_talks_flutter/lib/widgets/filter_chips_bar.dart b/wien_talks/wien_talks_flutter/lib/widgets/filter_chips_bar.dart new file mode 100644 index 0000000..b0f6557 --- /dev/null +++ b/wien_talks/wien_talks_flutter/lib/widgets/filter_chips_bar.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:wien_talks_client/wien_talks_client.dart'; + +class FilterChipsBar extends StatelessWidget { + const FilterChipsBar({ + super.key, + required this.sort, + required this.today, + required this.nearby, + required this.onSortChanged, + required this.onTodayChanged, + required this.onNearbyPressed, + this.onClear, + }); + + final String sort; + final bool today; + final bool nearby; + + final ValueChanged onSortChanged; + final ValueChanged onTodayChanged; + final VoidCallback onNearbyPressed; + final VoidCallback? onClear; + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints.tightFor(height: 40), + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 8), + scrollDirection: Axis.horizontal, + children: [ + ChoiceChip( + label: const Text('New'), + selected: sort == 'new', + onSelected: (_) => onSortChanged('new'), + ), + const SizedBox(width: 8), + ChoiceChip( + label: const Text('Loveed'), + selected: sort == 'top', + onSelected: (_) => onSortChanged('top'), + ), + const SizedBox(width: 8), + FilterChip( + label: const Text('Today'), + selected: today, + onSelected: (v) => onTodayChanged(v), + ), + const SizedBox(width: 8), + FilterChip( + label: Text('Close by'), + selected: nearby, + onSelected: (_) => onNearbyPressed(), + ), + if (onClear != null && (today || nearby)) ...[ + const SizedBox(width: 8), + ActionChip( + label: const Text('Clear'), + onPressed: onClear, + ), + ], + ], + ), + ); + } +} diff --git a/wien_talks/wien_talks_flutter/lib/widgets/filter_overlay.dart b/wien_talks/wien_talks_flutter/lib/widgets/filter_overlay.dart new file mode 100644 index 0000000..b3043e0 --- /dev/null +++ b/wien_talks/wien_talks_flutter/lib/widgets/filter_overlay.dart @@ -0,0 +1,142 @@ +import 'package:flutter/material.dart'; +import 'package:wien_talks_flutter/helper/location_filter.dart'; + +class LocationFilterSheet extends StatefulWidget { + const LocationFilterSheet({super.key, this.current}); + final LocationFilter? current; + + @override + State createState() => _LocationFilterSheetState(); +} + +class _LocationFilterSheetState extends State { + final _latCtrl = TextEditingController(); + final _lonCtrl = TextEditingController(); + + double? _lat; + double? _lon; + double _radius = 1000; + + @override + void initState() { + super.initState(); + final c = widget.current; + _lat = c?.centerLat; + _lon = c?.centerLon; + _radius = c?.radiusMeters ?? _radius; + _latCtrl.text = _lat?.toStringAsFixed(6) ?? ''; + _lonCtrl.text = _lon?.toStringAsFixed(6) ?? ''; + _latCtrl.addListener(() => _lat = double.tryParse(_latCtrl.text)); + _lonCtrl.addListener(() => _lon = double.tryParse(_lonCtrl.text)); + } + + @override + void dispose() { + _latCtrl.dispose(); + _lonCtrl.dispose(); + super.dispose(); + } + + bool get _coordsValid { + final lat = _lat, lon = _lon; + return lat != null && + lon != null && + lat >= -90 && + lat <= 90 && + lon >= -180 && + lon <= 180; + } + + @override + Widget build(BuildContext context) { + final bottomInset = MediaQuery.of(context).viewInsets.bottom; + + return SafeArea( + child: SingleChildScrollView( + padding: EdgeInsets.fromLTRB(16, 16, 16, 16 + bottomInset), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Location filter', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)), + const SizedBox(height: 12), + + //todo(timo) fix decimal point display + TextField( + controller: _latCtrl, + decoration: InputDecoration( + labelText: 'Center latitude', + errorText: (_lat == null || (_lat! >= -90 && _lat! <= 90)) + ? null + : ' between −90 and 90', + ), + keyboardType: const TextInputType.numberWithOptions( + signed: true, decimal: true), + ), + const SizedBox(height: 8), + TextField( + controller: _lonCtrl, + decoration: InputDecoration( + labelText: 'Center longitude', + errorText: (_lon == null || (_lon! >= -180 && _lon! <= 180)) + ? null + : ' between −180 and 180', + ), + keyboardType: const TextInputType.numberWithOptions( + signed: true, decimal: true), + ), + const SizedBox(height: 12), + Row( + children: [ + const Text('Radius'), + Expanded( + child: Slider( + value: _radius, + min: 200, + max: 5000, + divisions: 24, + label: + '${(_radius / 1000).toStringAsFixed(_radius < 1000 ? 1 : 0)} km', + onChanged: (v) => setState(() => _radius = v), + ), + ), + SizedBox( + width: 72, + child: Text('${(_radius / 1000).toStringAsFixed(1)} km'), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => + Navigator.pop(context, null), + child: const Text('Cancel'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: FilledButton( + onPressed: _coordsValid + ? () => Navigator.pop( + context, + LocationFilter( + centerLat: _lat!, + centerLon: _lon!, + radiusMeters: _radius, + ), + ) + : null, + child: const Text('Apply'), + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/wien_talks/wien_talks_flutter/lib/mapfile_widget.dart b/wien_talks/wien_talks_flutter/lib/widgets/mapfile_widget.dart similarity index 100% rename from wien_talks/wien_talks_flutter/lib/mapfile_widget.dart rename to wien_talks/wien_talks_flutter/lib/widgets/mapfile_widget.dart