Compare commits

..

5 commits

Author SHA1 Message Date
tk
eb5264a553 display ubahn tapes on grid cards 2025-08-17 04:31:12 +02:00
tk
1f2934146e add map previews to grid cards 2025-08-17 04:30:47 +02:00
tk
75813426a1 add dotenv to client 2025-08-17 03:51:47 +02:00
tk
424722d31e admit defeat and use clanker code 2025-08-17 02:53:59 +02:00
tk
37fbaf4f72 address linter warnigns 2025-08-17 02:32:19 +02:00
12 changed files with 684 additions and 45 deletions

View file

@ -45,3 +45,5 @@ app.*.map.json
/android/app/profile
/android/app/release
.env

View file

@ -0,0 +1,29 @@
import 'package:flutter_dotenv/flutter_dotenv.dart';
String gStaticMap(
double lat,
double lon, {
int w = 600,
int h = 360,
int zoom = 15,
int scale = 2,
String markerHex = 'E53935',
String maptype = 'roadmap',
String language = 'de',
String region = 'AT',
}) {
final mapsApiKey = dotenv.env['MAPS_KEY'] ?? '';
final qp = <String, String>{
'center': '$lat,$lon',
'zoom': '$zoom',
'size': '${w}x$h',
'scale': '$scale',
'maptype': maptype,
'format': 'png',
'language': language,
'region': region,
'markers': 'color:0x$markerHex|$lat,$lon',
'key': mapsApiKey,
};
return Uri.https('maps.googleapis.com', '/maps/api/staticmap', qp).toString();
}

View file

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

View file

@ -11,24 +11,23 @@ class NewsScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ScreenWidget(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
HeadingText(text: "What's being said"),
LatestQuotesScreen(),
SizedBox(
height: 30,
),
ElevatedButton(
onPressed: () {
context.pushNamed("create_event");
},
child: Text("Submit your own event")),
],
var column = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
HeadingText(text: "What's being said"),
LatestQuotesScreen(),
SizedBox(
height: 30,
),
),
ElevatedButton(
onPressed: () {
context.pushNamed("create_event");
},
child: Text("Submit your own event")),
],
);
return ScreenWidget(
child: LatestQuotesScreen(),
);
}
}

View file

@ -1,10 +1,12 @@
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/quote_card.dart';
import 'package:wien_talks_flutter/widgets/flamboyant_quote_card.dart';
class LatestQuotesScreen extends StatefulWidget {
const LatestQuotesScreen({super.key});
@ -45,10 +47,11 @@ class _LatestQuotesScreenState extends State<LatestQuotesScreen> {
void _upsert(Quote q) {
final i = _quotes.indexWhere((x) => x.id == q.id);
if (i >= 0)
if (i >= 0) {
_quotes[i] = q;
else
} else {
_quotes.add(q);
}
_quotes.sort((a, b) => b.createdAt.compareTo(a.createdAt));
}
@ -101,16 +104,12 @@ class _LatestQuotesScreenState extends State<LatestQuotesScreen> {
return LayoutBuilder(
builder: (context, constraints) {
final unboundedHeight = constraints.maxHeight == double.infinity;
final list = ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 8),
shrinkWrap: unboundedHeight,
physics: unboundedHeight
? const NeverScrollableScrollPhysics()
: const AlwaysScrollableScrollPhysics(),
return MasonryGridView.count(
crossAxisCount: 2,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
itemCount: _quotes.length,
separatorBuilder: (_, __) => const SizedBox(height: 6),
itemBuilder: (context, i) {
final q = _quotes[i];
final author = (q.authorName ?? '').trim();
@ -119,18 +118,14 @@ class _LatestQuotesScreenState extends State<LatestQuotesScreen> {
timeAgo(q.createdAt),
].join(' · ');
return QuoteCard(
quote: q,
meta: meta,
onVoteUp: () => _vote(q, true),
onVoteDown: () => _vote(q, false),
);
return FlamboyantQuoteCard(
quote: q,
meta: meta,
onVoteUp: () => _vote(q, true),
onVoteDown: () => _vote(q, false),
staticMapUrlBuilder: gStaticMap);
},
);
return unboundedHeight
? list
: RefreshIndicator(onRefresh: () async {}, child: list);
},
);
}

View file

@ -0,0 +1,224 @@
import 'package:flutter/material.dart';
import 'package:wien_talks_client/wien_talks_client.dart';
import 'package:wien_talks_flutter/widgets/flamboyant_quote_card.dart';
import 'package:wien_talks_flutter/widgets/map_preview_widget.dart';
class CardContenty extends StatelessWidget {
const CardContenty({
super.key,
required this.quote,
required this.staticMapUrlBuilder,
required this.meta,
required this.onVoteUp,
required this.onVoteDown,
required this.context,
required this.variant,
required this.accent,
required this.metaStyle,
});
final Quote quote;
final StaticMapUrlBuilder? staticMapUrlBuilder;
final String meta;
final VoidCallback onVoteUp;
final VoidCallback onVoteDown;
final BuildContext context;
final int variant;
final Color accent;
final TextStyle? metaStyle;
@override
Widget build(BuildContext context) {
final hasMap = (variant != 0);
final map = hasMap
? Padding(
padding: const EdgeInsets.only(bottom: 8),
child: MapPreview(
lat: quote.lat,
lon: quote.long,
accent: accent,
staticMapUrlBuilder: staticMapUrlBuilder,
),
)
: const SizedBox.shrink();
final textBlock = _ContentText(
quote: quote,
meta: meta,
metaStyle: metaStyle,
onVoteUp: onVoteUp,
onVoteDown: onVoteDown,
accent: accent);
stacked() => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [map, textBlock],
);
return switch (variant) {
1 when hasMap => stacked(),
2 when hasMap => LayoutBuilder(
builder: (context, c) {
final wide = c.maxWidth >= 420;
return wide
? Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Flexible(
flex: 5,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 8, 12),
child: MapPreview(
lat: quote.lat,
lon: quote.long,
accent: accent,
staticMapUrlBuilder: staticMapUrlBuilder,
aspect: 4 / 3,
),
),
),
Flexible(flex: 7, child: textBlock),
],
)
: stacked();
},
),
_ => textBlock,
};
}
}
class _ContentText extends StatelessWidget {
const _ContentText({
required this.quote,
required this.meta,
required this.metaStyle,
required this.onVoteUp,
required this.onVoteDown,
required this.accent,
});
final Quote quote;
final String meta;
final TextStyle? metaStyle;
final VoidCallback onVoteUp;
final VoidCallback onVoteDown;
final Color accent;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 12, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
quote.text,
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: Text(meta,
style: metaStyle, overflow: TextOverflow.ellipsis)),
const SizedBox(width: 8),
_VotePills(
up: quote.upvotes,
down: quote.downvotes,
onUp: onVoteUp,
onDown: onVoteDown,
accent: accent,
),
],
),
],
),
);
}
}
class _VotePills extends StatelessWidget {
const _VotePills({
required this.up,
required this.down,
required this.onUp,
required this.onDown,
required this.accent,
});
final int up;
final int down;
final VoidCallback onUp;
final VoidCallback onDown;
final Color accent;
@override
Widget build(BuildContext context) {
final t = Theme.of(context);
final bg = t.colorScheme.surfaceContainerHighest.withValues(alpha: 0.55);
final onBg = ThemeData.estimateBrightnessForColor(bg) == Brightness.dark
? Colors.white
: const Color(0xFF1A1A1A);
final pillStyle = t.textTheme.labelSmall?.copyWith(color: onBg);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
_Pill(
icon: Icons.arrow_upward,
color: accent,
text: '$up',
onTap: onUp,
textStyle: pillStyle,
),
const SizedBox(width: 6),
_Pill(
icon: Icons.arrow_downward,
color: const Color(0xFFD32F2F),
text: '$down',
onTap: onDown,
textStyle: pillStyle,
),
],
);
}
}
class _Pill extends StatelessWidget {
const _Pill({
required this.icon,
required this.color,
required this.text,
required this.onTap,
required this.textStyle,
});
final IconData icon;
final Color color;
final String text;
final VoidCallback onTap;
final TextStyle? textStyle;
@override
Widget build(BuildContext context) {
return Material(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(999),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(999),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Row(
children: [
Icon(icon, size: 16, color: color),
const SizedBox(width: 6),
Text(text, style: textStyle),
],
),
),
),
);
}
}

View file

@ -0,0 +1,101 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:wien_talks_client/wien_talks_client.dart';
import 'package:wien_talks_flutter/widgets/card_contenty.dart';
import 'package:wien_talks_flutter/widgets/ubahn_tape.dart';
typedef StaticMapUrlBuilder = String Function(
double lat,
double lon, {
int w,
int h,
int zoom,
});
class FlamboyantQuoteCard extends StatelessWidget {
const FlamboyantQuoteCard({
super.key,
required this.quote,
required this.meta,
required this.onVoteUp,
required this.onVoteDown,
this.staticMapUrlBuilder,
});
final Quote quote;
final String meta;
final VoidCallback onVoteUp;
final VoidCallback onVoteDown;
final StaticMapUrlBuilder? staticMapUrlBuilder;
@override
Widget build(BuildContext context) {
final seed = (quote.id ?? quote.text.hashCode) & 0x7fffffff;
final rng = math.Random(seed);
final variant = (rng.nextInt(3));
// Subtle tilt and accent
final tiltDeg = [-2.2, -1.4, -0.6, 0, 0.6, 1.2, 2.0][rng.nextInt(7)];
final tiltRad = tiltDeg * math.pi / 180.0;
final accents = [
const Color(0xFFE53935), // red
const Color(0xFF3949AB), // indigo
const Color(0xFF00897B), // teal
];
final accent = accents[seed % accents.length];
final t = Theme.of(context);
final metaStyle = t.textTheme.bodySmall?.copyWith(
color: (t.textTheme.bodySmall?.color ?? t.colorScheme.onSurface)
.withValues(alpha: 0.70),
);
final card = Container(
decoration: BoxDecoration(
color: t.colorScheme.surface,
borderRadius: BorderRadius.circular(14),
boxShadow: const [
BoxShadow(
color: Color(0x14000000),
blurRadius: 12,
offset: Offset(0, 6),
),
],
border: Border.all(color: accent.withValues(alpha: 0.25), width: 1),
),
child: CardContenty(
quote: quote,
staticMapUrlBuilder: staticMapUrlBuilder,
meta: meta,
onVoteUp: onVoteUp,
onVoteDown: onVoteDown,
context: context,
variant: variant,
accent: accent,
metaStyle: metaStyle),
);
return Padding(
padding: const EdgeInsets.only(top: 6, bottom: 2),
child: Stack(
clipBehavior: Clip.none,
children: [
Transform.rotate(
angle: tiltRad,
child: card,
),
Positioned(
top: -8,
right: 16,
child: UbahnTape(
lat: quote.lat,
lon: quote.long,
),
),
],
),
);
}
}

View file

@ -0,0 +1,92 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:wien_talks_flutter/widgets/flamboyant_quote_card.dart';
class MapPreview extends StatelessWidget {
const MapPreview({
super.key,
required this.lat,
required this.lon,
required this.accent,
this.staticMapUrlBuilder,
this.aspect = 16 / 9,
});
final double lat;
final double lon;
final double aspect;
final Color accent;
final StaticMapUrlBuilder? staticMapUrlBuilder;
@override
Widget build(BuildContext context) {
final border = Border.all(color: accent.withValues(alpha: 0.25), width: 1);
final r = BorderRadius.circular(12);
final urlBuilder = staticMapUrlBuilder;
final w =
(MediaQuery.of(context).size.width / 2).clamp(280.0, 600.0).toInt();
final h = (w / aspect).round();
Widget content;
if (urlBuilder != null) {
final url = urlBuilder(lat, lon, w: w, h: h, zoom: 15);
content = ClipRRect(
borderRadius: r,
child: CachedNetworkImage(
imageUrl: url,
width: double.infinity,
height: h.toDouble(),
fit: BoxFit.cover,
),
);
} else {
content =
_MapPlaceholder(accent: accent, height: h.toDouble(), radius: r);
}
return DecoratedBox(
decoration: BoxDecoration(border: border, borderRadius: r),
child: content,
);
}
}
class _MapPlaceholder extends StatelessWidget {
const _MapPlaceholder({required this.accent, this.height, this.radius});
final Color accent;
final double? height;
final BorderRadius? radius;
@override
Widget build(BuildContext context) {
final t = Theme.of(context);
return ClipRRect(
borderRadius: radius ?? BorderRadius.circular(12),
child: Container(
height: height,
alignment: Alignment.center,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
t.colorScheme.surfaceContainerHighest.withValues(alpha: 0.50),
t.colorScheme.surfaceContainerHighest.withValues(alpha: 0.20),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.location_on, color: accent),
const SizedBox(width: 8),
Text('Map preview', style: t.textTheme.labelMedium),
],
),
),
);
}
}

View file

@ -13,6 +13,7 @@ class NewsInputForm extends StatefulWidget {
const NewsInputForm({super.key, required this.onSubmit});
@override
// ignore: library_private_types_in_public_api
_NewsInputFormState createState() => _NewsInputFormState();
}
@ -45,7 +46,7 @@ class _NewsInputFormState extends State<NewsInputForm> {
try {
await widget.onSubmit(newsData);
} catch (error) {
if (context.mounted) {
if (mounted) {
ErrorSnackbar().show(context, error.toString());
}
} finally {

View file

@ -13,9 +13,6 @@ class ScreenWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('FunMap'),
),
floatingActionButton: AddQuoteFab(),
body: SafeArea(
child: Padding(

View file

@ -0,0 +1,193 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
class UbahnTape extends StatelessWidget {
const UbahnTape({
super.key,
this.lat,
this.lon,
this.rotationDeg = 6,
this.maxLinesShown = 1,
this.stations = kViennaStationsSample,
});
final double? lat;
final double? lon;
final double rotationDeg;
final int maxLinesShown;
final List<UbahnStation> stations;
@override
Widget build(BuildContext context) {
final lines = _resolveLines();
final primary = lines.isNotEmpty
? (kUbahnLineColors[lines.first] ?? _kNeutral)
: _kNeutral;
final bg = primary.withValues(alpha: 0.30);
return Transform.rotate(
angle: rotationDeg * math.pi / 180,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(3),
boxShadow: const [
BoxShadow(
color: Color(0x33000000),
blurRadius: 4,
offset: Offset(0, 2),
),
],
border: Border.all(
color: primary.withValues(alpha: 0.35),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 14,
height: 14,
alignment: Alignment.center,
decoration: BoxDecoration(
color: _uBlue,
borderRadius: BorderRadius.circular(3),
),
child: const Text(
'U',
style: TextStyle(
fontWeight: FontWeight.w800,
fontSize: 10,
height: 1.0,
color: Colors.white,
),
),
),
const SizedBox(width: 6),
if (lines.isNotEmpty)
...lines.take(maxLinesShown).expand((line) => [
_LineChip(line: line),
const SizedBox(width: 4),
]),
],
),
),
);
}
List<String> _resolveLines() {
if (lat == null || lon == null) return const [];
final nearest = _nearestStation(stations, lat!, lon!);
return nearest?.lines ?? const [];
}
}
class _LineChip extends StatelessWidget {
const _LineChip({required this.line});
final String line;
@override
Widget build(BuildContext context) {
final color = kUbahnLineColors[line] ?? _kNeutral;
final on = _onColor(color);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(999),
),
child: Text(
line,
style: TextStyle(
color: on,
fontSize: 10,
fontWeight: FontWeight.w700,
height: 1.0,
letterSpacing: 0.2,
),
),
);
}
}
Color _onColor(Color bg) {
return ThemeData.estimateBrightnessForColor(bg) == Brightness.dark
? Colors.white
: const Color(0xFF111111);
}
const _uBlue = Color(0xFF1E88E5); // Vienna U sign-ish blue
const _kNeutral = Color(0xFF9E9E9E);
// Official-ish line colors
const Map<String, Color> kUbahnLineColors = {
'U1': Color(0xFFE20613), // red
'U2': Color(0xFFA762A3), // purple
'U3': Color(0xFFF29400), // orange
'U4': Color(0xFF009640), // green
'U5': Color(0xFF63318F), // violet (future)
'U6': Color(0xFF8D5B2D), // brown
};
class UbahnStation {
const UbahnStation(this.name, this.lat, this.lon, this.lines);
final String name;
final double lat;
final double lon;
final List<String> lines; // e.g., ['U1','U3']
}
UbahnStation? _nearestStation(
List<UbahnStation> stations,
double lat,
double lon,
) {
if (stations.isEmpty) return null;
UbahnStation best = stations.first;
double bestD = _haversine(best.lat, best.lon, lat, lon);
for (var i = 1; i < stations.length; i++) {
final s = stations[i];
final d = _haversine(s.lat, s.lon, lat, lon);
if (d < bestD) {
best = s;
bestD = d;
}
}
return best;
}
double _haversine(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);
final c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a));
return R * c;
}
// Compact central sample; swap in full dataset when ready
const List<UbahnStation> kViennaStationsSample = [
UbahnStation('Stephansplatz', 48.2084, 16.3731, ['U1', 'U3']),
UbahnStation('Karlsplatz', 48.2000, 16.3690, ['U1', 'U2', 'U4']),
UbahnStation('Schwedenplatz', 48.2111, 16.3776, ['U1', 'U4']),
UbahnStation('Praterstern', 48.2169, 16.3909, ['U1', 'U2']),
UbahnStation('Schottenring', 48.2152, 16.3720, ['U2', 'U4']),
UbahnStation('Volkstheater', 48.2078, 16.3604, ['U2', 'U3']),
UbahnStation('Museumsquartier', 48.2026, 16.3614, ['U2']),
UbahnStation('Westbahnhof', 48.1967, 16.3378, ['U3', 'U6']),
UbahnStation('Wien Mitte/Landstraße', 48.2070, 16.3834, ['U3', 'U4']),
UbahnStation('Spittelau', 48.2409, 16.3585, ['U4', 'U6']),
UbahnStation('Längenfeldgasse', 48.1848, 16.3299, ['U4', 'U6']),
UbahnStation('Erdberg', 48.1907, 16.4196, ['U3']),
UbahnStation('Kaisermühlen VIC', 48.2348, 16.4130, ['U1']),
UbahnStation('Floridsdorf', 48.2570, 16.4030, ['U6']),
UbahnStation('Ottakring', 48.2120, 16.3080, ['U3']),
];

View file

@ -52,6 +52,9 @@ dependencies:
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
google_sign_in: ^7.1.1
flutter_staggered_grid_view: ^0.7.0
cached_network_image: ^3.4.1
flutter_dotenv: ^5.2.1
dev_dependencies:
flutter_lints: '>=3.0.0 <7.0.0'
@ -71,6 +74,7 @@ flutter:
# To add assets to your application, add an assets section, like this:
assets:
- .env
- assets/funny_images/
- assets/render_themes/
- packages/mapsforge_flutter/assets/patterns/dark_farmland.svg