diff --git a/wien_talks/wien_talks_flutter/lib/helper/location_util.dart b/wien_talks/wien_talks_flutter/lib/helper/location_util.dart new file mode 100644 index 0000000..22b390f --- /dev/null +++ b/wien_talks/wien_talks_flutter/lib/helper/location_util.dart @@ -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 = { + '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(); +} diff --git a/wien_talks/wien_talks_flutter/lib/main.dart b/wien_talks/wien_talks_flutter/lib/main.dart index 0842ce8..1f585ca 100644 --- a/wien_talks/wien_talks_flutter/lib/main.dart +++ b/wien_talks/wien_talks_flutter/lib/main.dart @@ -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 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, ); diff --git a/wien_talks/wien_talks_flutter/lib/screens/news_screen.dart b/wien_talks/wien_talks_flutter/lib/screens/news_screen.dart index 4ec3a2a..79d2c14 100644 --- a/wien_talks/wien_talks_flutter/lib/screens/news_screen.dart +++ b/wien_talks/wien_talks_flutter/lib/screens/news_screen.dart @@ -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(), ); } } diff --git a/wien_talks/wien_talks_flutter/lib/screens/show_latest_news_widget.dart b/wien_talks/wien_talks_flutter/lib/screens/show_latest_news_widget.dart index ce4cb54..062b24b 100644 --- a/wien_talks/wien_talks_flutter/lib/screens/show_latest_news_widget.dart +++ b/wien_talks/wien_talks_flutter/lib/screens/show_latest_news_widget.dart @@ -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}); @@ -102,16 +104,12 @@ class _LatestQuotesScreenState extends State { return LayoutBuilder( builder: (context, constraints) { - final unboundedHeight = constraints.maxHeight == double.infinity; - - return 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(); @@ -120,12 +118,12 @@ class _LatestQuotesScreenState extends State { 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); }, ); }, diff --git a/wien_talks/wien_talks_flutter/lib/widgets/card_contenty.dart b/wien_talks/wien_talks_flutter/lib/widgets/card_contenty.dart new file mode 100644 index 0000000..2e8e211 --- /dev/null +++ b/wien_talks/wien_talks_flutter/lib/widgets/card_contenty.dart @@ -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), + ], + ), + ), + ), + ); + } +} diff --git a/wien_talks/wien_talks_flutter/lib/widgets/flamboyant_quote_card.dart b/wien_talks/wien_talks_flutter/lib/widgets/flamboyant_quote_card.dart new file mode 100644 index 0000000..5ac0f3b --- /dev/null +++ b/wien_talks/wien_talks_flutter/lib/widgets/flamboyant_quote_card.dart @@ -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, + ), + ), + ], + ), + ); + } +} diff --git a/wien_talks/wien_talks_flutter/lib/widgets/map_preview_widget.dart b/wien_talks/wien_talks_flutter/lib/widgets/map_preview_widget.dart new file mode 100644 index 0000000..f6f5117 --- /dev/null +++ b/wien_talks/wien_talks_flutter/lib/widgets/map_preview_widget.dart @@ -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), + ], + ), + ), + ); + } +}