mirror of
https://github.com/timokz/flutter-vienna-hackathon-25.git
synced 2025-11-09 05:44:19 +01:00
Compare commits
No commits in common. "2b84f749aea3681eac1a20d29da87c6d52accdda" and "eb5264a5533a4f4e2bbd8e2898ba02a1df6cd85a" have entirely different histories.
2b84f749ae
...
eb5264a553
15 changed files with 147 additions and 602 deletions
1
wien_talks/wien_talks_flutter/.gitignore
vendored
1
wien_talks/wien_talks_flutter/.gitignore
vendored
|
|
@ -44,7 +44,6 @@ 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,48 +1,29 @@
|
||||||
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_17
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
}
|
}
|
||||||
kotlinOptions { jvmTarget = "17" }
|
|
||||||
|
|
||||||
signingConfigs {
|
kotlinOptions {
|
||||||
if (keystorePropertiesFile.exists()) {
|
jvmTarget = JavaVersion.VERSION_11.toString()
|
||||||
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
|
||||||
|
|
@ -51,18 +32,9 @@ android {
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
signingConfig = signingConfigs.findByName("release")
|
// TODO: Add your own signing config for the release build.
|
||||||
?: signingConfigs.getByName("debug")
|
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||||
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
isMinifyEnabled = true
|
|
||||||
isShrinkResources = true
|
|
||||||
|
|
||||||
proguardFiles(
|
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
|
||||||
"proguard-rules.pro"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
debug {
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
-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.**
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
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,7 +1,6 @@
|
||||||
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');
|
||||||
|
|
@ -15,7 +14,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: GemeindeBauTheme.light(),
|
theme: ThemeData(primarySwatch: Colors.green),
|
||||||
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/widgets/mapfile_widget.dart';
|
import 'package:wien_talks_flutter/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/latest_quotes_screen.dart';
|
import 'package:wien_talks_flutter/screens/show_latest_news_widget.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';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,221 +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_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/latest_quotes_screen.dart';
|
import 'package:wien_talks_flutter/screens/show_latest_news_widget.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';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,132 @@
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,142 +0,0 @@
|
||||||
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