mirror of
https://github.com/timokz/flutter-vienna-hackathon-25.git
synced 2025-11-08 17:14:21 +01:00
add map previews to grid cards
This commit is contained in:
parent
75813426a1
commit
1f2934146e
7 changed files with 480 additions and 35 deletions
29
wien_talks/wien_talks_flutter/lib/helper/location_util.dart
Normal file
29
wien_talks/wien_talks_flutter/lib/helper/location_util.dart
Normal 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();
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<LatestQuotesScreen> {
|
|||
|
||||
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<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);
|
||||
},
|
||||
);
|
||||
},
|
||||
|
|
|
|||
224
wien_talks/wien_talks_flutter/lib/widgets/card_contenty.dart
Normal file
224
wien_talks/wien_talks_flutter/lib/widgets/card_contenty.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue