add filters to grid view

This commit is contained in:
tk 2025-08-17 07:59:38 +02:00
parent 8bde15320b
commit 2b84f749ae
10 changed files with 473 additions and 136 deletions

View file

@ -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));
}
}

View file

@ -0,0 +1,12 @@
import 'package:flutter/foundation.dart';
import 'location_filter.dart';
class FilterController extends ValueNotifier<LocationFilter?> {
FilterController([super.initial]);
void clear() => value = null;
void setLocation(double lat, double lon, double radiusMeters) {
value = LocationFilter(
centerLat: lat, centerLon: lon, radiusMeters: radiusMeters);
}
}

View file

@ -3,7 +3,7 @@ import 'package:location/location.dart';
import 'package:wien_talks_client/wien_talks_client.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/widgets/get_location_widget.dart';
import 'package:wien_talks_flutter/helper/funmap_mgr.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/news_input_form.dart';
import 'package:wien_talks_flutter/widgets/screen_widget.dart'; import 'package:wien_talks_flutter/widgets/screen_widget.dart';

View file

@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:wien_talks_flutter/helper/funmap_mgr.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/intro_text_widget.dart';
import 'package:wien_talks_flutter/widgets/screen_widget.dart'; import 'package:wien_talks_flutter/widgets/screen_widget.dart';

View file

@ -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<LatestQuotesScreen> createState() => _LatestQuotesScreenState();
}
class _LatestQuotesScreenState extends State<LatestQuotesScreen> {
final List<Quote> _quotes = [];
StreamSubscription<Quote>? _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<void> _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<Quote> 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<Quote> 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<void> _pickLocationFilter() async {
final picked = await showModalBottomSheet<LocationFilter>(
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,
);
},
)),
],
);
}
}

View file

@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.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/heading_text.dart';
import 'package:wien_talks_flutter/widgets/screen_widget.dart'; import 'package:wien_talks_flutter/widgets/screen_widget.dart';

View file

@ -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<LatestQuotesScreen> createState() => _LatestQuotesScreenState();
}
class _LatestQuotesScreenState extends State<LatestQuotesScreen> {
final List<Quote> _quotes = [];
StreamSubscription<Quote>? _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<void> _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);
},
);
},
);
}
}

View file

@ -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<String> onSortChanged;
final ValueChanged<bool> 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,
),
],
],
),
);
}
}

View file

@ -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<LocationFilterSheet> createState() => _LocationFilterSheetState();
}
class _LocationFilterSheetState extends State<LocationFilterSheet> {
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<LocationFilter?>(context, null),
child: const Text('Cancel'),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton(
onPressed: _coordsValid
? () => Navigator.pop<LocationFilter>(
context,
LocationFilter(
centerLat: _lat!,
centerLon: _lon!,
radiusMeters: _radius,
),
)
: null,
child: const Text('Apply'),
),
),
],
),
],
),
),
);
}
}