diff --git a/wien_talks/wien_talks_flutter/lib/helper/funmap_mgr.dart b/wien_talks/wien_talks_flutter/lib/helper/funmap_mgr.dart index 846e1ad..c39639d 100644 --- a/wien_talks/wien_talks_flutter/lib/helper/funmap_mgr.dart +++ b/wien_talks/wien_talks_flutter/lib/helper/funmap_mgr.dart @@ -26,8 +26,9 @@ class FunmapMgr { // E.g. `flutter run --dart-define=SERVER_URL=https://api.example.com/` const serverUrlFromEnv = String.fromEnvironment('SERVER_URL'); - serverUrl = - serverUrlFromEnv.isEmpty ? 'http://$localhost:8080/' : serverUrlFromEnv; + serverUrl = serverUrlFromEnv.isEmpty + ? 'https://wien-talks-api.aequito.sh/' + : serverUrlFromEnv; client = Client(serverUrl, connectionTimeout: const Duration(seconds: 2)) ..connectivityMonitor = FlutterConnectivityMonitor(); diff --git a/wien_talks/wien_talks_flutter/lib/helper/go_router.dart b/wien_talks/wien_talks_flutter/lib/helper/go_router.dart index 679cd7e..2266340 100644 --- a/wien_talks/wien_talks_flutter/lib/helper/go_router.dart +++ b/wien_talks/wien_talks_flutter/lib/helper/go_router.dart @@ -1,15 +1,51 @@ +import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:wien_talks_client/wien_talks_client.dart'; import 'package:wien_talks_flutter/screens/create_event_screen.dart'; -import 'package:wien_talks_flutter/screens/login_page.dart'; import 'package:wien_talks_flutter/screens/news_screen.dart'; +double? _qpDouble(GoRouterState s, String key) { + final v = s.uri.queryParameters[key]; + return v == null || v.isEmpty ? null : double.tryParse(v); +} + final router = GoRouter( + initialLocation: '/', routes: [ - GoRoute(path: '/login', builder: (c, s) => const LoginScreen()), - GoRoute(path: '/', builder: (c, s) => NewsScreen()), GoRoute( - path: '/create_event', - name: 'create_event', - builder: (c, s) => CreateEventScreen()), + path: '/', + name: 'news', + builder: (c, s) => NewsScreen(), + ), + GoRoute( + path: '/create_event', + name: 'create_event', + pageBuilder: (c, s) { + final quote = s.extra as Quote?; + final lat = _qpDouble(s, 'lat'); + final lon = _qpDouble(s, 'lon'); + + return CustomTransitionPage( + key: s.pageKey, + child: CreateEventScreen( + contextQuote: quote, + initialLat: lat, + initialLon: lon, + ), + transitionsBuilder: (context, anim, secAnim, child) { + final curve = + CurvedAnimation(parent: anim, curve: Curves.easeOutCubic); + return FadeTransition( + opacity: curve, + child: SlideTransition( + position: Tween(begin: const Offset(0, 0.08), end: Offset.zero) + .animate(curve), + child: child, + ), + ); + }, + ); + }, + ), ], ); diff --git a/wien_talks/wien_talks_flutter/lib/screens/create_event_screen.dart b/wien_talks/wien_talks_flutter/lib/screens/create_event_screen.dart index 441809b..a0695c6 100644 --- a/wien_talks/wien_talks_flutter/lib/screens/create_event_screen.dart +++ b/wien_talks/wien_talks_flutter/lib/screens/create_event_screen.dart @@ -1,40 +1,195 @@ -import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; 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/carousel_widget.dart'; +import 'package:wien_talks_flutter/widgets/get_location_widget.dart'; import 'package:wien_talks_flutter/widgets/mapfile_widget.dart'; import 'package:wien_talks_flutter/widgets/news_input_form.dart'; import 'package:wien_talks_flutter/widgets/screen_widget.dart'; -import '../helper/location_mgr.dart'; +class CreateEventScreen extends StatefulWidget { + const CreateEventScreen({ + super.key, + this.contextQuote, + this.initialLat, + this.initialLon, + }); -class CreateEventScreen extends StatelessWidget { - const CreateEventScreen({super.key}); + final Quote? contextQuote; + final double? initialLat; + final double? initialLon; + + @override + State createState() => _CreateEventScreenState(); +} + +class _CreateEventScreenState extends State { + double? _lat; + double? _lon; + bool _submitting = false; + + @override + void initState() { + super.initState(); + _lat = widget.contextQuote?.lat ?? widget.initialLat; + _lon = widget.contextQuote?.long ?? widget.initialLon; + } + + Future _useMyLocation() async { + try { + final loc = await Location().getLocation(); + if (!mounted) return; + setState(() { + _lat = loc.latitude; + _lon = loc.longitude; + }); + } catch (_) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Couldn`’t get your location.')), + ); + } + } + + Future _submit(CreateQuoteRequest req) async { + final lat = _lat ?? widget.contextQuote?.lat; + final lon = _lon ?? widget.contextQuote?.long; + + if (lat == null || lon == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text(' location first.')), + ); + return; + } + + final toSend = CreateQuoteRequest( + text: req.text, + authorName: req.authorName, + lat: lat, + lng: lon, + ); + + setState(() => _submitting = true); + try { + await FunmapMgr().client.quote.createQuote(toSend); + if (!mounted) { + return; + } + + Navigator.pop(context); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Posting failed: $e')), + ); + } finally { + if (mounted) setState(() => _submitting = false); + } + } @override Widget build(BuildContext context) { + final hasContextQuote = widget.contextQuote != null; + final quote = widget.contextQuote; + return ScreenWidget( - child: Column( - children: [ - NewsInputForm( - onSubmit: (CreateQuoteRequest request) async { - await FunmapMgr().client.quote.createQuote(request); - }, - ), - StreamBuilder( - stream: LocationMgr().stream, - builder: - (BuildContext context, AsyncSnapshot snapshot) => - snapshot.data != null - ? Text(snapshot.data.toString()) - : SizedBox()), - Expanded( - child: GetLocationWidget( - child: MapfileWidget(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (hasContextQuote) ...[ + Card( + margin: const EdgeInsets.fromLTRB(12, 12, 12, 6), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(quote!.text, + style: Theme.of(context).textTheme.bodyLarge), + const SizedBox(height: 6), + Text( + [ + if ((quote.authorName ?? '').trim().isNotEmpty) + quote.authorName!.trim() + ].join(' · '), + style: Theme.of(context).textTheme.labelMedium, + ), + ], + ), + ), + ), + ], + Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 4), + child: Row( + children: [ + Expanded( + child: Text( + _lat != null && _lon != null + ? 'Location: ${_lat!.toStringAsFixed(5)}, ${_lon!.toStringAsFixed(5)}' + : 'Pick an alt location', + style: Theme.of(context).textTheme.labelMedium, + ), + ), + const SizedBox(width: 8), + FilledButton.icon( + onPressed: _useMyLocation, + icon: const Icon(Icons.my_location, size: 18), + label: const Text('My location'), + ), + ], + ), ), - ), - ], - )); + Padding( + padding: const EdgeInsets.fromLTRB(12, 4, 12, 8), + child: NewsInputForm( + // enabled: !_submitting, + onSubmit: _submit, + ), + ), + Expanded( + child: Stack( + children: [ + GetLocationWidget( + child: MapfileWidget(), + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: LocationCarousel( + suggestions: [ + if (widget.contextQuote?.lat != null && + widget.contextQuote?.long != null) + LocationSuggestion( + label: 'Quote location', + lat: widget.contextQuote!.lat, + lon: widget.contextQuote!.long, + assetImage: 'assets/funny_images/tram.jpg', + ), + const LocationSuggestion( + label: 'Stephansplatz', + lat: 48.2085, + lon: 16.3731, + assetImage: 'assets/funny_images/sightseeing.jpg'), + const LocationSuggestion( + label: 'Naschmarkt', + lat: 48.1970, + lon: 16.3615, + assetImage: 'assets/funny_images/houses.jpg'), + ], + onPick: (lat, lon) => setState(() { + _lat = lat; + _lon = lon; + }), + ), + ), + ], + ), + ), + ], + ), + ); } } diff --git a/wien_talks/wien_talks_flutter/lib/screens/home_screen.dart b/wien_talks/wien_talks_flutter/lib/screens/home_screen.dart index 86357e0..7cbbfc6 100644 --- a/wien_talks/wien_talks_flutter/lib/screens/home_screen.dart +++ b/wien_talks/wien_talks_flutter/lib/screens/home_screen.dart @@ -5,8 +5,6 @@ import 'package:wien_talks_flutter/screens/latest_quotes_screen.dart'; import 'package:wien_talks_flutter/widgets/intro_text_widget.dart'; import 'package:wien_talks_flutter/widgets/screen_widget.dart'; -import '../widgets/carousel_widget.dart'; - class HomeScreen extends StatelessWidget { const HomeScreen({ super.key, @@ -43,7 +41,6 @@ class HomeScreen extends StatelessWidget { SizedBox( height: 30, ), - CarouselWidget(), Row( children: [ Spacer(), diff --git a/wien_talks/wien_talks_flutter/lib/screens/latest_quotes_screen.dart b/wien_talks/wien_talks_flutter/lib/screens/latest_quotes_screen.dart index ce9a6e2..ca63f40 100644 --- a/wien_talks/wien_talks_flutter/lib/screens/latest_quotes_screen.dart +++ b/wien_talks/wien_talks_flutter/lib/screens/latest_quotes_screen.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; +import 'package:go_router/go_router.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'; @@ -212,6 +213,14 @@ class _LatestQuotesScreenState extends State { _applyFilters(); }, staticMapUrlBuilder: gStaticMap, + onTap: () => context.pushNamed( + 'create_event', + extra: q, + queryParameters: { + 'lat': q.lat.toString(), + 'lon': q.long.toString(), + }, + ), ); }, )), diff --git a/wien_talks/wien_talks_flutter/lib/screens/login_page.dart b/wien_talks/wien_talks_flutter/lib/screens/login_page.dart deleted file mode 100644 index 1eff639..0000000 --- a/wien_talks/wien_talks_flutter/lib/screens/login_page.dart +++ /dev/null @@ -1,50 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; -import 'package:wien_talks_flutter/helper/auth_service.dart'; - -class LoginScreen extends StatelessWidget { - const LoginScreen({super.key}); - - @override - Widget build(BuildContext context) { - return Scaffold( - body: Container( - decoration: const BoxDecoration( - gradient: LinearGradient( - colors: [Color(0xff2193b0), Color(0xff6dd5ed)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - ), - alignment: Alignment.center, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text('Wien Talks', - style: GoogleFonts.poppins( - fontSize: 42, - fontWeight: FontWeight.bold, - color: Colors.white)), - const SizedBox(height: 60), - FilledButton.icon( - onPressed: () async => await AuthService.signIn(), - style: FilledButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: Colors.black87, - padding: - const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(30)), - elevation: 6, - ), - icon: Icon( - Icons.lock, - ), - label: const Text('Sign in with Google'), - ), - ], - ), - ), - ); - } -} diff --git a/wien_talks/wien_talks_flutter/lib/widgets/carousel_widget.dart b/wien_talks/wien_talks_flutter/lib/widgets/carousel_widget.dart index 087b75d..5b383a4 100644 --- a/wien_talks/wien_talks_flutter/lib/widgets/carousel_widget.dart +++ b/wien_talks/wien_talks_flutter/lib/widgets/carousel_widget.dart @@ -1,25 +1,110 @@ import 'package:carousel_slider/carousel_slider.dart'; import 'package:flutter/material.dart'; -class CarouselWidget extends StatelessWidget { - const CarouselWidget({super.key}); +class LocationSuggestion { + final String label; + final double lat; + final double lon; + final String? subtitle; + final String? assetImage; + const LocationSuggestion({ + required this.label, + required this.lat, + required this.lon, + this.subtitle, + this.assetImage, + }); +} + +class LocationCarousel extends StatelessWidget { + const LocationCarousel({ + super.key, + required this.suggestions, + required this.onPick, + this.height = 96, + }); + + final List suggestions; + final void Function(double lat, double lon) onPick; + final double height; @override Widget build(BuildContext context) { - return IgnorePointer( - child: CarouselSlider( - options: CarouselOptions(height: 300.0, autoPlay: true), - items: ["houses.jpg", "kangaroos.jpg", "sightseeing.jpg", "tram.jpg", "fiaker.jpg", "falco.jpg", "wastebin.jpg"].map((i) { - return Builder( - builder: (BuildContext context) { - return Container( - width: MediaQuery.of(context).size.width, - margin: EdgeInsets.symmetric(horizontal: 5.0), - //decoration: BoxDecoration(color: Colors.amber), - child: Image(image: AssetImage("assets/funny_images/$i"))); - }, - ); - }).toList(), + if (suggestions.isEmpty) return const SizedBox.shrink(); + + return SafeArea( + top: false, + child: Card( + margin: const EdgeInsets.fromLTRB(12, 0, 12, 12), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: CarouselSlider( + options: CarouselOptions( + height: height, + viewportFraction: 0.72, + enlargeCenterPage: true, + enableInfiniteScroll: suggestions.length > 1, + ), + items: suggestions.map((s) { + return InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () => onPick(s.lat, s.lon), + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 6), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant, + ), + ), + child: Row( + children: [ + if (s.assetImage != null) + ClipRRect( + borderRadius: const BorderRadius.horizontal( + left: Radius.circular(12)), + child: Image.asset( + s.assetImage!, + width: 90, + height: double.infinity, + fit: BoxFit.cover, + ), + ) + else + const SizedBox(width: 12), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(s.label, + style: Theme.of(context).textTheme.labelLarge, + maxLines: 1, + overflow: TextOverflow.ellipsis), + if (s.subtitle != null) + Text(s.subtitle!, + style: + Theme.of(context).textTheme.labelSmall, + maxLines: 1, + overflow: TextOverflow.ellipsis), + Text( + '${s.lat.toStringAsFixed(4)}, ${s.lon.toStringAsFixed(4)}', + style: Theme.of(context).textTheme.labelSmall, + ), + ], + ), + ), + ), + ], + ), + ), + ); + }).toList(), + ), + ), ), ); } 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 index 5ac0f3b..8d9c740 100644 --- a/wien_talks/wien_talks_flutter/lib/widgets/flamboyant_quote_card.dart +++ b/wien_talks/wien_talks_flutter/lib/widgets/flamboyant_quote_card.dart @@ -21,6 +21,7 @@ class FlamboyantQuoteCard extends StatelessWidget { required this.onVoteUp, required this.onVoteDown, this.staticMapUrlBuilder, + this.onTap, }); final Quote quote; @@ -28,21 +29,20 @@ class FlamboyantQuoteCard extends StatelessWidget { final VoidCallback onVoteUp; final VoidCallback onVoteDown; final StaticMapUrlBuilder? staticMapUrlBuilder; + final VoidCallback? onTap; @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 variant = rng.nextInt(3); 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 + const Color(0xFFE53935), + const Color(0xFF3949AB), + const Color(0xFF00897B), ]; final accent = accents[seed % accents.length]; @@ -52,29 +52,41 @@ class FlamboyantQuoteCard extends StatelessWidget { .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), + final borderRadius = BorderRadius.circular(14); + + final cardContent = CardContenty( + quote: quote, + staticMapUrlBuilder: staticMapUrlBuilder, + meta: meta, + onVoteUp: onVoteUp, + onVoteDown: onVoteDown, + context: context, + variant: variant, + accent: accent, + metaStyle: metaStyle, + ); + + final tappableCard = Material( + type: MaterialType.transparency, + child: Ink( + decoration: BoxDecoration( + color: t.colorScheme.surface, + borderRadius: borderRadius, + boxShadow: const [ + BoxShadow( + color: Color(0x14000000), + blurRadius: 12, + offset: Offset(0, 6), + ), + ], + border: Border.all(color: accent.withValues(alpha: 0.25), width: 1), + ), + child: InkWell( + borderRadius: borderRadius, + onTap: onTap, + child: cardContent, + ), ), - child: CardContenty( - quote: quote, - staticMapUrlBuilder: staticMapUrlBuilder, - meta: meta, - onVoteUp: onVoteUp, - onVoteDown: onVoteDown, - context: context, - variant: variant, - accent: accent, - metaStyle: metaStyle), ); return Padding( @@ -84,14 +96,17 @@ class FlamboyantQuoteCard extends StatelessWidget { children: [ Transform.rotate( angle: tiltRad, - child: card, + transformHitTests: false, + child: tappableCard, ), Positioned( top: -8, right: 16, - child: UbahnTape( - lat: quote.lat, - lon: quote.long, + child: IgnorePointer( + child: UbahnTape( + lat: quote.lat, + lon: quote.long, + ), ), ), ],