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:
2026-05-14 20:13:57 -07:00
commit b239ae3e5f
208 changed files with 19187 additions and 0 deletions
@@ -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,
),
),
);
}
}