introduce bottom modal for quote entry

This commit is contained in:
tk 2025-08-17 01:44:23 +02:00
parent 39e4f1142f
commit bf28ff429a
2 changed files with 205 additions and 9 deletions

View file

@ -1,17 +1,199 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:location/location.dart';
import 'package:wien_talks_client/wien_talks_client.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/funmap_mgr.dart';
class QuoteDraft {
final String text;
final String? author;
const QuoteDraft({required this.text, this.author});
}
typedef QuoteSubmit = FutureOr<void> Function(QuoteDraft draft);
/// If Simon reads this I'm sorry
class AddQuoteFab extends StatelessWidget { class AddQuoteFab extends StatelessWidget {
const AddQuoteFab({ const AddQuoteFab({
super.key, super.key,
this.tooltip = 'Add quote',
this.icon = Icons.format_quote_rounded,
this.mini = false,
}); });
final String tooltip;
final IconData icon;
final bool mini;
Future<void> onSubmit(QuoteDraft draft) async {
final fix = await _getHackyLocation();
if (fix == null || fix.latitude == null) {
return;
}
await FunmapMgr().client.quote.createQuote(
CreateQuoteRequest(
text: draft.text,
authorName: draft.author,
lat: fix.latitude!,
lng: fix.longitude!,
),
);
}
Future<LocationData?> _getHackyLocation() async {
final loc = Location();
if (!await loc.serviceEnabled()) {
if (!await loc.requestService()) return null;
}
var perm = await loc.hasPermission();
if (perm == PermissionStatus.denied) {
perm = await loc.requestPermission();
if (perm != PermissionStatus.granted) return null;
}
return await loc.getLocation();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return FloatingActionButton(onPressed: () { return FloatingActionButton(
FunmapMgr().client.quote.createQuote( mini: mini,
CreateQuoteRequest(text: 'Quote Text', lat: 22, lng: 140)); tooltip: tooltip,
}); onPressed: () async {
final draft = await showQuoteEditor(context);
if (draft != null) {
await onSubmit(draft);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Quote saved')),
);
}
}
},
child: Icon(icon),
);
} }
} }
Future<QuoteDraft?> showQuoteEditor(
BuildContext context, {
String? initialText,
String? initialAuthor,
}) async {
final textCtrl = TextEditingController(text: initialText ?? '');
final authorCtrl = TextEditingController(text: initialAuthor ?? '');
const maxChars = 500;
return showModalBottomSheet<QuoteDraft>(
context: context,
isScrollControlled: true,
useSafeArea: true,
showDragHandle: true,
builder: (ctx) {
bool canSave() {
final t = textCtrl.text.trim();
return t.isNotEmpty && t.length <= maxChars;
}
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(ctx).viewInsets.bottom,
),
child: StatefulBuilder(
builder: (ctx, setSheetState) {
void onChanged(_) => setSheetState(() {});
final remaining = maxChars - textCtrl.text.characters.length;
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Text('New quote', style: Theme.of(ctx).textTheme.titleMedium),
const SizedBox(height: 8),
TextField(
controller: textCtrl,
onChanged: onChanged,
autofocus: true,
maxLines: null,
minLines: 3,
textInputAction: TextInputAction.newline,
decoration: InputDecoration(
labelText: 'Quote text',
hintText: 'How is Vienna surprising you today',
helperText: 'Max $maxChars characters',
counterText:
'${textCtrl.text.characters.length}/$maxChars',
border: const OutlineInputBorder(),
),
maxLength: maxChars,
maxLengthEnforcement: MaxLengthEnforcement.enforced,
),
const SizedBox(height: 12),
TextField(
controller: authorCtrl,
textInputAction: TextInputAction.done,
decoration: const InputDecoration(
labelText: 'Author (optional)',
hintText: 'e.g., Schmausi Wamperl',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => Navigator.of(ctx).pop(null),
child: const Text('Cancel'),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton.icon(
onPressed: canSave()
? () => Navigator.of(ctx).pop(
QuoteDraft(
text: textCtrl.text.trim(),
author: authorCtrl.text.trim().isEmpty
? null
: authorCtrl.text.trim(),
),
)
: null,
icon: const Icon(Icons.check),
label: const Text('Save'),
),
),
],
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: Text(
remaining >= 0
? '$remaining characters left'
: '${-remaining} over limit',
style: Theme.of(ctx).textTheme.labelSmall?.copyWith(
color: Theme.of(ctx)
.colorScheme
.onSurface
.withValues(alpha: 0.7),
),
),
),
],
),
);
},
),
);
},
);
}

View file

@ -3,6 +3,7 @@ import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_spinkit/flutter_spinkit.dart'; import 'package:flutter_spinkit/flutter_spinkit.dart';
import 'package:loader_overlay/loader_overlay.dart'; import 'package:loader_overlay/loader_overlay.dart';
import 'package:wien_talks_flutter/widgets/add_quote_fab.dart';
class ScreenWidget extends StatelessWidget { class ScreenWidget extends StatelessWidget {
final Widget child; final Widget child;
@ -15,7 +16,16 @@ class ScreenWidget extends StatelessWidget {
appBar: AppBar( appBar: AppBar(
title: const Text('FunMap'), title: const Text('FunMap'),
), ),
// floatingActionButton: AddQuoteFab(), floatingActionButton: AddQuoteFab(
// onSubmit: (draft) {
// FunmapMgr().client.quote.createQuote(CreateQuoteRequest(
// text: draft.text,
// authorName: draft.author,
// lat: LocationMgr().lastLocation!.latitude!,
// lng: LocationMgr().lastLocation!.longitude!,
// ));
// },
),
body: SafeArea( body: SafeArea(
child: Padding( child: Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
@ -28,7 +38,8 @@ class ScreenWidget extends StatelessWidget {
); );
case 1: case 1:
return Center( return Center(
child: SpinKitCubeGrid(size: 50, color: Theme.of(context).primaryColor), child: SpinKitCubeGrid(
size: 50, color: Theme.of(context).primaryColor),
); );
case 2: case 2:
return Center( return Center(
@ -36,15 +47,18 @@ class ScreenWidget extends StatelessWidget {
); );
case 3: case 3:
return Center( return Center(
child: SpinKitHourGlass(color: Theme.of(context).primaryColor), child:
SpinKitHourGlass(color: Theme.of(context).primaryColor),
); );
case 4: case 4:
return Center( return Center(
child: SpinKitFadingCircle(color: Theme.of(context).primaryColor), child: SpinKitFadingCircle(
color: Theme.of(context).primaryColor),
); );
default: default:
return Center( return Center(
child: SpinKitPulsingGrid(color: Theme.of(context).primaryColor), child: SpinKitPulsingGrid(
color: Theme.of(context).primaryColor),
); );
} }
}, },