Initial commit: Flutter app + PHP/MySQL backend on Hostinger
Replaces Firebase with a self-hosted PHP/MySQL API served from winded.prymsolutions.com. Includes full backend (schema, auth, events, teams, brackets, suggestions, stats, media, file upload) and updated Flutter repositories and domain models. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../../auth/application/auth_notifier.dart';
|
||||
import '../domain/suggestion.dart';
|
||||
import '../infrastructure/suggestions_repository.dart';
|
||||
|
||||
part 'suggestions_notifier.g.dart';
|
||||
|
||||
/// Tracks the submission lifecycle of the suggestion form.
|
||||
///
|
||||
/// State is an `AsyncValue<void>`:
|
||||
/// * idle → `AsyncData(null)` after [build]
|
||||
/// * busy → `AsyncLoading()` while a submit is in flight
|
||||
/// * done → `AsyncData(null)` after a successful submit
|
||||
/// * error → `AsyncError(...)` on failure
|
||||
@riverpod
|
||||
class SuggestionsNotifier extends _$SuggestionsNotifier {
|
||||
@override
|
||||
Future<void> build() async {
|
||||
return;
|
||||
}
|
||||
|
||||
/// Submits a suggestion. UI should already have validated [text] length
|
||||
/// before calling — this method does not re-validate.
|
||||
Future<void> submit({
|
||||
required String text,
|
||||
required bool isAnonymous,
|
||||
String? userId,
|
||||
String? displayName,
|
||||
}) async {
|
||||
final repo = ref.read(suggestionsRepositoryProvider);
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
await repo.submitSuggestion(
|
||||
text: text.trim(),
|
||||
isAnonymous: isAnonymous,
|
||||
userId: isAnonymous ? null : userId,
|
||||
displayName: isAnonymous ? null : displayName,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Streams the current user's previously-submitted suggestions.
|
||||
///
|
||||
/// Emits an empty list when the user is signed out, so the UI can render a
|
||||
/// stable widget tree without juggling auth-vs-stream loading states.
|
||||
@riverpod
|
||||
Stream<List<Suggestion>> userSuggestions(UserSuggestionsRef ref) async* {
|
||||
final auth = ref.watch(authNotifierProvider);
|
||||
final user = auth.valueOrNull;
|
||||
if (user == null) {
|
||||
yield <Suggestion>[];
|
||||
return;
|
||||
}
|
||||
final repo = ref.watch(suggestionsRepositoryProvider);
|
||||
yield* repo.watchUserSuggestions(user.uid);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'suggestions_notifier.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$userSuggestionsHash() => r'8544dca51c0cb3453bfc7219fde2ec43e55b3106';
|
||||
|
||||
/// Streams the current user's previously-submitted suggestions.
|
||||
///
|
||||
/// Emits an empty list when the user is signed out, so the UI can render a
|
||||
/// stable widget tree without juggling auth-vs-stream loading states.
|
||||
///
|
||||
/// Copied from [userSuggestions].
|
||||
@ProviderFor(userSuggestions)
|
||||
final userSuggestionsProvider =
|
||||
AutoDisposeStreamProvider<List<Suggestion>>.internal(
|
||||
userSuggestions,
|
||||
name: r'userSuggestionsProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$userSuggestionsHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
typedef UserSuggestionsRef = AutoDisposeStreamProviderRef<List<Suggestion>>;
|
||||
String _$suggestionsNotifierHash() =>
|
||||
r'f7a4d35220e955e11bbd10872c8e2d838cc1a3a7';
|
||||
|
||||
/// Tracks the submission lifecycle of the suggestion form.
|
||||
///
|
||||
/// State is an `AsyncValue<void>`:
|
||||
/// * idle → `AsyncData(null)` after [build]
|
||||
/// * busy → `AsyncLoading()` while a submit is in flight
|
||||
/// * done → `AsyncData(null)` after a successful submit
|
||||
/// * error → `AsyncError(...)` on failure
|
||||
///
|
||||
/// Copied from [SuggestionsNotifier].
|
||||
@ProviderFor(SuggestionsNotifier)
|
||||
final suggestionsNotifierProvider =
|
||||
AutoDisposeAsyncNotifierProvider<SuggestionsNotifier, void>.internal(
|
||||
SuggestionsNotifier.new,
|
||||
name: r'suggestionsNotifierProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$suggestionsNotifierHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$SuggestionsNotifier = AutoDisposeAsyncNotifier<void>;
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
|
||||
@@ -0,0 +1,108 @@
|
||||
enum SuggestionStatus { pending, reviewed, implemented }
|
||||
|
||||
class Suggestion {
|
||||
const Suggestion({
|
||||
required this.id,
|
||||
required this.text,
|
||||
required this.isAnonymous,
|
||||
required this.submittedAt,
|
||||
required this.status,
|
||||
this.userId,
|
||||
this.displayName,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String text;
|
||||
final bool isAnonymous;
|
||||
final String? userId;
|
||||
final String? displayName;
|
||||
final DateTime submittedAt;
|
||||
final SuggestionStatus status;
|
||||
|
||||
Suggestion copyWith({
|
||||
String? id,
|
||||
String? text,
|
||||
bool? isAnonymous,
|
||||
String? userId,
|
||||
String? displayName,
|
||||
DateTime? submittedAt,
|
||||
SuggestionStatus? status,
|
||||
}) {
|
||||
return Suggestion(
|
||||
id: id ?? this.id,
|
||||
text: text ?? this.text,
|
||||
isAnonymous: isAnonymous ?? this.isAnonymous,
|
||||
userId: userId ?? this.userId,
|
||||
displayName: displayName ?? this.displayName,
|
||||
submittedAt: submittedAt ?? this.submittedAt,
|
||||
status: status ?? this.status,
|
||||
);
|
||||
}
|
||||
|
||||
factory Suggestion.fromJson(Map<String, dynamic> data) {
|
||||
return Suggestion(
|
||||
id: (data['id'] as String?) ?? '',
|
||||
text: (data['text'] as String?) ?? '',
|
||||
isAnonymous: _parseBool(data['is_anonymous']),
|
||||
userId: data['user_id'] as String?,
|
||||
displayName: data['display_name'] as String?,
|
||||
submittedAt: _parseDate(data['submitted_at']) ?? DateTime.now(),
|
||||
status: _parseStatus(data['status'] as String?),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object?> toJson() {
|
||||
return <String, Object?>{
|
||||
'text': text,
|
||||
'is_anonymous': isAnonymous,
|
||||
'user_id': isAnonymous ? null : userId,
|
||||
'display_name': isAnonymous ? null : displayName,
|
||||
'status': status.name,
|
||||
};
|
||||
}
|
||||
|
||||
static DateTime? _parseDate(Object? v) {
|
||||
if (v is String && v.isNotEmpty) return DateTime.tryParse(v);
|
||||
return null;
|
||||
}
|
||||
|
||||
static bool _parseBool(Object? v) {
|
||||
if (v is bool) return v;
|
||||
if (v is int) return v != 0;
|
||||
if (v is String) return v == '1' || v.toLowerCase() == 'true';
|
||||
return false;
|
||||
}
|
||||
|
||||
static SuggestionStatus _parseStatus(String? raw) {
|
||||
switch (raw) {
|
||||
case 'reviewed':
|
||||
return SuggestionStatus.reviewed;
|
||||
case 'implemented':
|
||||
return SuggestionStatus.implemented;
|
||||
default:
|
||||
return SuggestionStatus.pending;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is Suggestion &&
|
||||
other.id == id &&
|
||||
other.text == text &&
|
||||
other.isAnonymous == isAnonymous &&
|
||||
other.userId == userId &&
|
||||
other.displayName == displayName &&
|
||||
other.submittedAt == submittedAt &&
|
||||
other.status == status;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
id, text, isAnonymous, userId, displayName, submittedAt, status,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'Suggestion(id: $id, status: ${status.name}, anonymous: $isAnonymous)';
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../../../core/api/api_client.dart';
|
||||
import '../domain/suggestion.dart';
|
||||
|
||||
part 'suggestions_repository.g.dart';
|
||||
|
||||
class SuggestionsRepository {
|
||||
SuggestionsRepository(this._api);
|
||||
|
||||
final ApiClient _api;
|
||||
|
||||
Future<void> submitSuggestion({
|
||||
required String text,
|
||||
required bool isAnonymous,
|
||||
String? userId,
|
||||
String? displayName,
|
||||
}) async {
|
||||
await _api.post('/suggestions/index.php', {
|
||||
'text': text,
|
||||
'is_anonymous': isAnonymous,
|
||||
'display_name': displayName ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<Suggestion>> fetchUserSuggestions() async {
|
||||
final data = await _api.get('/suggestions/index.php');
|
||||
final list = (data['suggestions'] as List?) ?? [];
|
||||
return list.whereType<Map<String, dynamic>>().map(Suggestion.fromJson).toList();
|
||||
}
|
||||
|
||||
Future<List<Suggestion>> fetchAllSuggestions() async {
|
||||
final data = await _api.get('/suggestions/index.php');
|
||||
final list = (data['suggestions'] as List?) ?? [];
|
||||
return list.whereType<Map<String, dynamic>>().map(Suggestion.fromJson).toList();
|
||||
}
|
||||
|
||||
Future<void> updateStatus(String id, SuggestionStatus status) async {
|
||||
await _api.put(
|
||||
'/suggestions/detail.php',
|
||||
{'status': status.name},
|
||||
params: {'id': id},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteSuggestion(String id) async {
|
||||
await _api.delete('/suggestions/detail.php', params: {'id': id});
|
||||
}
|
||||
|
||||
Stream<List<Suggestion>> watchUserSuggestions(String userId) async* {
|
||||
yield await fetchUserSuggestions();
|
||||
await for (final _ in Stream<void>.periodic(const Duration(seconds: 30))) {
|
||||
yield await fetchUserSuggestions();
|
||||
}
|
||||
}
|
||||
|
||||
Stream<List<Suggestion>> watchAllSuggestions() async* {
|
||||
yield await fetchAllSuggestions();
|
||||
await for (final _ in Stream<void>.periodic(const Duration(seconds: 30))) {
|
||||
yield await fetchAllSuggestions();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
SuggestionsRepository suggestionsRepository(SuggestionsRepositoryRef ref) {
|
||||
return SuggestionsRepository(ref.watch(apiClientProvider));
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'suggestions_repository.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$suggestionsRepositoryHash() =>
|
||||
r'5cf92a23c07a7d135224b0fcd2831f68f4a9a27f';
|
||||
|
||||
/// See also [suggestionsRepository].
|
||||
@ProviderFor(suggestionsRepository)
|
||||
final suggestionsRepositoryProvider = Provider<SuggestionsRepository>.internal(
|
||||
suggestionsRepository,
|
||||
name: r'suggestionsRepositoryProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$suggestionsRepositoryHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
typedef SuggestionsRepositoryRef = ProviderRef<SuggestionsRepository>;
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
|
||||
@@ -0,0 +1,192 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../auth/application/auth_notifier.dart';
|
||||
import '../application/suggestions_notifier.dart';
|
||||
import 'widgets/suggestion_form.dart';
|
||||
import 'widgets/suggestion_list_tile.dart';
|
||||
|
||||
/// Top-level Suggestions screen.
|
||||
///
|
||||
/// Top half is the always-visible [SuggestionForm]. Bottom half lists the
|
||||
/// signed-in user's past suggestions via [userSuggestionsProvider], or a
|
||||
/// gentle sign-in prompt when there's no current user.
|
||||
class SuggestionsScreen extends ConsumerWidget {
|
||||
const SuggestionsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
final authUser = ref.watch(authNotifierProvider).valueOrNull;
|
||||
final suggestionsAsync = ref.watch(userSuggestionsProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Suggestions')),
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
const SuggestionForm(),
|
||||
const SizedBox(height: 24),
|
||||
const Divider(),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Your Suggestions',
|
||||
style: theme.textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (authUser == null)
|
||||
_SignInPrompt(colors: colors, textTheme: theme.textTheme)
|
||||
else
|
||||
suggestionsAsync.when(
|
||||
loading: () => const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
error: (err, _) => _ErrorState(
|
||||
message: 'Could not load your suggestions.',
|
||||
detail: '$err',
|
||||
colors: colors,
|
||||
textTheme: theme.textTheme,
|
||||
),
|
||||
data: (suggestions) {
|
||||
if (suggestions.isEmpty) {
|
||||
return _EmptyState(
|
||||
colors: colors,
|
||||
textTheme: theme.textTheme,
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
for (final s in suggestions)
|
||||
SuggestionListTile(suggestion: s),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SignInPrompt extends StatelessWidget {
|
||||
const _SignInPrompt({required this.colors, required this.textTheme});
|
||||
|
||||
final ColorScheme colors;
|
||||
final TextTheme textTheme;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Icon(Icons.lock_outline, color: colors.onSurfaceVariant),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Sign in to view your past suggestions.',
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyState extends StatelessWidget {
|
||||
const _EmptyState({required this.colors, required this.textTheme});
|
||||
|
||||
final ColorScheme colors;
|
||||
final TextTheme textTheme;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Icon(
|
||||
Icons.lightbulb_outline,
|
||||
size: 36,
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'No suggestions yet — share your first idea above.',
|
||||
textAlign: TextAlign.center,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ErrorState extends StatelessWidget {
|
||||
const _ErrorState({
|
||||
required this.message,
|
||||
required this.detail,
|
||||
required this.colors,
|
||||
required this.textTheme,
|
||||
});
|
||||
|
||||
final String message;
|
||||
final String detail;
|
||||
final ColorScheme colors;
|
||||
final TextTheme textTheme;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.errorContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Icon(Icons.error_outline, color: colors.onErrorContainer),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: colors.onErrorContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
detail,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colors.onErrorContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../auth/application/auth_notifier.dart';
|
||||
import '../../application/suggestions_notifier.dart';
|
||||
|
||||
/// Form for submitting a new community suggestion.
|
||||
///
|
||||
/// Owns its own [TextEditingController] and the "anonymous" toggle. Wires
|
||||
/// into [suggestionsNotifierProvider] for submission state — the FilledButton
|
||||
/// switches to a spinner while loading, and shows snackbars on success/error.
|
||||
class SuggestionForm extends ConsumerStatefulWidget {
|
||||
const SuggestionForm({super.key});
|
||||
|
||||
static const int _maxChars = 500;
|
||||
static const int _minChars = 10;
|
||||
|
||||
@override
|
||||
ConsumerState<SuggestionForm> createState() => _SuggestionFormState();
|
||||
}
|
||||
|
||||
class _SuggestionFormState extends ConsumerState<SuggestionForm> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _controller = TextEditingController();
|
||||
bool _isAnonymous = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String? _validate(String? value) {
|
||||
final text = value?.trim() ?? '';
|
||||
if (text.length < SuggestionForm._minChars) {
|
||||
return 'Please write at least ${SuggestionForm._minChars} characters.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _onSubmit() async {
|
||||
if (!(_formKey.currentState?.validate() ?? false)) return;
|
||||
FocusScope.of(context).unfocus();
|
||||
|
||||
final user = ref.read(authNotifierProvider).valueOrNull;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
|
||||
await ref.read(suggestionsNotifierProvider.notifier).submit(
|
||||
text: _controller.text,
|
||||
isAnonymous: _isAnonymous,
|
||||
userId: user?.uid,
|
||||
displayName: user?.displayName,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
final state = ref.read(suggestionsNotifierProvider);
|
||||
state.when(
|
||||
data: (_) {
|
||||
_controller.clear();
|
||||
_formKey.currentState?.reset();
|
||||
setState(() {});
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('Thanks for your suggestion!')),
|
||||
);
|
||||
},
|
||||
loading: () {},
|
||||
error: (err, _) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Could not submit suggestion: $err'),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
final submissionState = ref.watch(suggestionsNotifierProvider);
|
||||
final isSubmitting = submissionState.isLoading;
|
||||
final authUser = ref.watch(authNotifierProvider).valueOrNull;
|
||||
|
||||
final submittingAs = (!_isAnonymous && authUser != null)
|
||||
? (authUser.displayName?.trim().isNotEmpty ?? false
|
||||
? authUser.displayName!.trim()
|
||||
: authUser.email)
|
||||
: null;
|
||||
|
||||
return Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'Share an idea',
|
||||
style: theme.textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Tell us what would make Winded better — new features, '
|
||||
'tournament formats, anything.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _controller,
|
||||
enabled: !isSubmitting,
|
||||
minLines: 3,
|
||||
maxLines: 8,
|
||||
maxLength: SuggestionForm._maxChars,
|
||||
textInputAction: TextInputAction.newline,
|
||||
keyboardType: TextInputType.multiline,
|
||||
inputFormatters: <TextInputFormatter>[
|
||||
LengthLimitingTextInputFormatter(SuggestionForm._maxChars),
|
||||
],
|
||||
validator: _validate,
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Type your suggestion here…',
|
||||
border: OutlineInputBorder(),
|
||||
alignLabelWithHint: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SwitchListTile.adaptive(
|
||||
value: _isAnonymous,
|
||||
onChanged: isSubmitting
|
||||
? null
|
||||
: (value) => setState(() => _isAnonymous = value),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Submit anonymously'),
|
||||
subtitle: Text(
|
||||
_isAnonymous
|
||||
? 'Your name will not be attached to this suggestion.'
|
||||
: 'Admins will see who submitted this.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (submittingAs != null) ...<Widget>[
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Icon(
|
||||
Icons.person_outline,
|
||||
size: 16,
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Submitting as: $submittingAs',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: isSubmitting ? null : _onSubmit,
|
||||
icon: isSubmitting
|
||||
? SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: colors.onPrimary,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.send_outlined),
|
||||
label: Text(isSubmitting ? 'Submitting…' : 'Submit Suggestion'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../domain/suggestion.dart';
|
||||
|
||||
/// Card-style row showing one of the current user's past suggestions.
|
||||
///
|
||||
/// Truncates the body to three lines, prints a friendly relative-ish date,
|
||||
/// and renders a status chip whose color matches the suggestion lifecycle.
|
||||
class SuggestionListTile extends StatelessWidget {
|
||||
const SuggestionListTile({super.key, required this.suggestion});
|
||||
|
||||
final Suggestion suggestion;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
final dateLabel =
|
||||
DateFormat.yMMMd().add_jm().format(suggestion.submittedAt);
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Text(
|
||||
suggestion.text,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_StatusChip(status: suggestion.status),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Icon(
|
||||
Icons.schedule,
|
||||
size: 14,
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
dateLabel,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (suggestion.isAnonymous) ...<Widget>[
|
||||
const SizedBox(width: 12),
|
||||
Icon(
|
||||
Icons.visibility_off_outlined,
|
||||
size: 14,
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'Anonymous',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusChip extends StatelessWidget {
|
||||
const _StatusChip({required this.status});
|
||||
|
||||
final SuggestionStatus status;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
|
||||
final (Color background, Color foreground, String label) = switch (status) {
|
||||
SuggestionStatus.pending => (
|
||||
colors.surfaceContainerHighest,
|
||||
colors.onSurfaceVariant,
|
||||
'Pending',
|
||||
),
|
||||
SuggestionStatus.reviewed => (
|
||||
Colors.amber.withValues(alpha: 0.18),
|
||||
Colors.amber.shade300,
|
||||
'Reviewed',
|
||||
),
|
||||
SuggestionStatus.implemented => (
|
||||
Colors.green.withValues(alpha: 0.18),
|
||||
Colors.green.shade300,
|
||||
'Implemented',
|
||||
),
|
||||
};
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: background,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: foreground,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user