Compare commits

..

No commits in common. "2b84f749aea3681eac1a20d29da87c6d52accdda" and "eb5264a5533a4f4e2bbd8e2898ba02a1df6cd85a" have entirely different histories.

15 changed files with 147 additions and 602 deletions

View file

@ -44,7 +44,6 @@ app.*.map.json
/android/app/debug
/android/app/profile
/android/app/release
android/key.properties
.env

View file

@ -1,48 +1,29 @@
import java.util.Properties
import java.io.FileInputStream
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
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 {
namespace = "com.wien_talks"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions { jvmTarget = "17" }
signingConfigs {
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
}
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_11.toString()
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
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
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
@ -51,18 +32,9 @@ android {
buildTypes {
release {
signingConfig = signingConfigs.findByName("release")
?: signingConfigs.getByName("debug")
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
debug {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}

View file

@ -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.**

View file

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

View file

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

View file

@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:wien_talks_flutter/helper/go_router.dart';
import 'package:wien_talks_flutter/theme.dart';
Future<void> main() async {
await dotenv.load(fileName: '.env');
@ -15,7 +14,7 @@ class MyApp extends StatelessWidget {
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'Wien Talks',
theme: GemeindeBauTheme.light(),
theme: ThemeData(primarySwatch: Colors.green),
routerConfig: router,
);
}

View file

@ -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/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/screen_widget.dart';

View file

@ -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/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/screen_widget.dart';

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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