mirror of
https://github.com/timokz/flutter-vienna-hackathon-25.git
synced 2025-11-08 19:04:20 +01:00
Compare commits
3 commits
eb5264a553
...
2b84f749ae
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b84f749ae | ||
|
|
8bde15320b | ||
|
|
fd77a38f97 |
15 changed files with 602 additions and 147 deletions
1
wien_talks/wien_talks_flutter/.gitignore
vendored
1
wien_talks/wien_talks_flutter/.gitignore
vendored
|
|
@ -44,6 +44,7 @@ app.*.map.json
|
||||||
/android/app/debug
|
/android/app/debug
|
||||||
/android/app/profile
|
/android/app/profile
|
||||||
/android/app/release
|
/android/app/release
|
||||||
|
android/key.properties
|
||||||
|
|
||||||
|
|
||||||
.env
|
.env
|
||||||
|
|
@ -1,29 +1,48 @@
|
||||||
|
import java.util.Properties
|
||||||
|
import java.io.FileInputStream
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("kotlin-android")
|
id("kotlin-android")
|
||||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
|
||||||
id("dev.flutter.flutter-gradle-plugin")
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation("com.google.android.play:core:1.10.3")
|
||||||
|
}
|
||||||
|
|
||||||
|
val keystoreProperties = Properties()
|
||||||
|
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
FileInputStream(keystorePropertiesFile).use { keystoreProperties.load(it) }
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.wien_talks"
|
namespace = "com.wien_talks"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_11
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_11
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
kotlinOptions { jvmTarget = "17" }
|
||||||
|
|
||||||
kotlinOptions {
|
signingConfigs {
|
||||||
jvmTarget = JavaVersion.VERSION_11.toString()
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
create("release") {
|
||||||
|
keyAlias = keystoreProperties["keyAlias"] as String
|
||||||
|
keyPassword = keystoreProperties["keyPassword"] as String
|
||||||
|
val storePath = keystoreProperties["storeFile"] as String?
|
||||||
|
storeFile = storePath?.let { file(it) }
|
||||||
|
storePassword = keystoreProperties["storePassword"] as String
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
|
||||||
applicationId = "com.wien_talks"
|
applicationId = "com.wien_talks"
|
||||||
// You can update the following values to match your application needs.
|
|
||||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
|
||||||
minSdk = flutter.minSdkVersion
|
minSdk = flutter.minSdkVersion
|
||||||
targetSdk = flutter.targetSdkVersion
|
targetSdk = flutter.targetSdkVersion
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
|
|
@ -32,9 +51,18 @@ android {
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
// TODO: Add your own signing config for the release build.
|
signingConfig = signingConfigs.findByName("release")
|
||||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
?: signingConfigs.getByName("debug")
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
|
||||||
|
isMinifyEnabled = true
|
||||||
|
isShrinkResources = true
|
||||||
|
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
debug {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
8
wien_talks/wien_talks_flutter/android/app/proguard-rules.pro
vendored
Normal file
8
wien_talks/wien_talks_flutter/android/app/proguard-rules.pro
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
-keep class io.flutter.embedding.** { *; }
|
||||||
|
-keep class io.flutter.plugins.** { *; }
|
||||||
|
-keep class io.flutter.** { *; }
|
||||||
|
|
||||||
|
-keep class com.google.android.gms.** { *; }
|
||||||
|
-dontwarn com.google.android.gms.**
|
||||||
|
|
||||||
|
-dontwarn kotlinx.coroutines.**
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||||
import 'package:wien_talks_flutter/helper/go_router.dart';
|
import 'package:wien_talks_flutter/helper/go_router.dart';
|
||||||
|
import 'package:wien_talks_flutter/theme.dart';
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
await dotenv.load(fileName: '.env');
|
await dotenv.load(fileName: '.env');
|
||||||
|
|
@ -14,7 +15,7 @@ class MyApp extends StatelessWidget {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MaterialApp.router(
|
return MaterialApp.router(
|
||||||
title: 'Wien Talks',
|
title: 'Wien Talks',
|
||||||
theme: ThemeData(primarySwatch: Colors.green),
|
theme: GemeindeBauTheme.light(),
|
||||||
routerConfig: router,
|
routerConfig: router,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,132 +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(
|
|
||||||
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);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
79
wien_talks/wien_talks_flutter/lib/theme.dart
Normal file
79
wien_talks/wien_talks_flutter/lib/theme.dart
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class GemeindeBauTheme {
|
||||||
|
static const _brand = Color(0xFFE20613);
|
||||||
|
static const _accent = Color(0xFF009640);
|
||||||
|
static const _radius = 14.0;
|
||||||
|
|
||||||
|
static ThemeData light() => _base(
|
||||||
|
ColorScheme.fromSeed(
|
||||||
|
seedColor: _brand,
|
||||||
|
brightness: Brightness.light,
|
||||||
|
).copyWith(
|
||||||
|
secondary: _accent,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
static ThemeData _base(
|
||||||
|
ColorScheme scheme,
|
||||||
|
) {
|
||||||
|
return ThemeData(
|
||||||
|
useMaterial3: true,
|
||||||
|
colorScheme: scheme,
|
||||||
|
scaffoldBackgroundColor: scheme.surface,
|
||||||
|
appBarTheme: AppBarTheme(
|
||||||
|
backgroundColor: scheme.surface,
|
||||||
|
foregroundColor: scheme.onSurface,
|
||||||
|
elevation: 0.5,
|
||||||
|
// surfaceTintColor: Colors.transparent,
|
||||||
|
centerTitle: false,
|
||||||
|
),
|
||||||
|
bottomSheetTheme: BottomSheetThemeData(
|
||||||
|
backgroundColor: scheme.surface,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius:
|
||||||
|
const BorderRadius.vertical(top: Radius.circular(_radius)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
floatingActionButtonTheme: FloatingActionButtonThemeData(
|
||||||
|
backgroundColor: scheme.primary,
|
||||||
|
foregroundColor: scheme.onPrimary,
|
||||||
|
elevation: 3,
|
||||||
|
shape: const StadiumBorder(),
|
||||||
|
),
|
||||||
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
|
filled: true,
|
||||||
|
fillColor: scheme.surfaceContainerHighest,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide(color: scheme.outlineVariant),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide(color: scheme.outlineVariant),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide(color: scheme.primary, width: 2),
|
||||||
|
),
|
||||||
|
contentPadding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
|
),
|
||||||
|
listTileTheme: ListTileThemeData(
|
||||||
|
iconColor: scheme.onSurfaceVariant,
|
||||||
|
textColor: scheme.onSurface,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
snackBarTheme: SnackBarThemeData(
|
||||||
|
backgroundColor: scheme.inverseSurface,
|
||||||
|
contentTextStyle: TextStyle(color: scheme.onInverseSurface),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
dividerColor: scheme.outlineVariant,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
142
wien_talks/wien_talks_flutter/lib/widgets/filter_overlay.dart
Normal file
142
wien_talks/wien_talks_flutter/lib/widgets/filter_overlay.dart
Normal 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'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue