From 5b1809f7fcf3533a04402fdd46951b4e0d5baf56 Mon Sep 17 00:00:00 2001 From: Lakshmidevi Thirupathi Date: Wed, 30 Sep 2026 12:29:00 +0530 Subject: [PATCH] Improve DoubtQueue UI and queue experience Co-Authored-By: Claude Sonnet 5.5 --- app/lib/main.dart | 6 +- app/lib/models/doubt.dart | 13 + app/lib/models/session.dart | 8 + app/lib/screens/join_queue_screen.dart | 210 +++++++---- app/lib/screens/mentor_screen.dart | 434 ++++++++++++----------- app/lib/screens/my_doubt_screen.dart | 82 ++--- app/lib/screens/session_list_screen.dart | 240 ++++++++----- app/lib/theme/app_theme.dart | 53 +++ app/lib/utils/session_filter.dart | 61 ++++ app/lib/utils/time_format.dart | 29 ++ app/lib/widgets/confirm_dialog.dart | 27 ++ app/lib/widgets/content_width.dart | 4 +- app/lib/widgets/create_session_card.dart | 121 +++++++ app/lib/widgets/current_doubt_card.dart | 100 ++++++ app/lib/widgets/queue_item_card.dart | 68 ++++ app/lib/widgets/queue_status_card.dart | 111 ++++++ app/lib/widgets/section_header.dart | 46 +++ app/lib/widgets/session_card.dart | 119 +++++++ app/lib/widgets/state_views.dart | 180 ++++++++++ app/lib/widgets/stats_row.dart | 82 +++++ app/lib/widgets/status_badge.dart | 127 +++++-- app/test/api_service_test.dart | 28 ++ app/test/fake_backend.dart | 169 +++++++++ app/test/join_queue_screen_test.dart | 95 +++++ app/test/mentor_screen_test.dart | 196 ++++++++++ app/test/models_test.dart | 28 +- app/test/my_doubt_screen_test.dart | 118 ++++++ app/test/responsive_test.dart | 109 ++++++ app/test/session_filter_test.dart | 94 +++++ app/test/session_list_screen_test.dart | 198 +++++++++-- app/test/time_format_test.dart | 43 +++ 31 files changed, 2710 insertions(+), 489 deletions(-) create mode 100644 app/lib/theme/app_theme.dart create mode 100644 app/lib/utils/session_filter.dart create mode 100644 app/lib/utils/time_format.dart create mode 100644 app/lib/widgets/confirm_dialog.dart create mode 100644 app/lib/widgets/create_session_card.dart create mode 100644 app/lib/widgets/current_doubt_card.dart create mode 100644 app/lib/widgets/queue_item_card.dart create mode 100644 app/lib/widgets/queue_status_card.dart create mode 100644 app/lib/widgets/section_header.dart create mode 100644 app/lib/widgets/session_card.dart create mode 100644 app/lib/widgets/state_views.dart create mode 100644 app/lib/widgets/stats_row.dart create mode 100644 app/test/fake_backend.dart create mode 100644 app/test/join_queue_screen_test.dart create mode 100644 app/test/mentor_screen_test.dart create mode 100644 app/test/my_doubt_screen_test.dart create mode 100644 app/test/responsive_test.dart create mode 100644 app/test/session_filter_test.dart create mode 100644 app/test/time_format_test.dart diff --git a/app/lib/main.dart b/app/lib/main.dart index e60f7b7..6018c8a 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -3,6 +3,7 @@ import 'package:http/http.dart' as http; import 'screens/session_list_screen.dart'; import 'services/api_service.dart'; +import 'theme/app_theme.dart'; void main() { runApp(DoubtQueueApp(api: ApiService(http.Client()))); @@ -17,10 +18,7 @@ class DoubtQueueApp extends StatelessWidget { Widget build(BuildContext context) { return MaterialApp( title: 'DoubtQueue', - theme: ThemeData( - useMaterial3: true, - colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal), - ), + theme: buildAppTheme(), home: SessionListScreen(api: api), ); } diff --git a/app/lib/models/doubt.dart b/app/lib/models/doubt.dart index e279e4e..9445f89 100644 --- a/app/lib/models/doubt.dart +++ b/app/lib/models/doubt.dart @@ -1,12 +1,17 @@ +import '../utils/time_format.dart'; + class Doubt { const Doubt({ required this.id, required this.sessionId, required this.studentName, + required this.studentEmail, required this.topic, required this.question, required this.status, this.position, + this.createdAt, + this.startedAt, }); factory Doubt.fromJson(Map json) { @@ -14,21 +19,29 @@ class Doubt { id: json['id'] as int, sessionId: json['session_id'] as int, studentName: json['student_name'] as String, + studentEmail: json['student_email'] as String, topic: json['topic'] as String, question: json['question'] as String, status: json['status'] as String, position: json['position'] as int?, + createdAt: parseApiDate(json['created_at'] as String?), + startedAt: parseApiDate(json['started_at'] as String?), ); } final int id; final int sessionId; final String studentName; + final String studentEmail; final String topic; final String question; final String status; final int? position; + final DateTime? createdAt; + final DateTime? startedAt; bool get isWaiting => status == 'WAITING'; bool get isFinished => status == 'SOLVED' || status == 'SKIPPED'; + + int get studentsAhead => position == null ? 0 : position! - 1; } diff --git a/app/lib/models/session.dart b/app/lib/models/session.dart index 86769b2..dffcaf9 100644 --- a/app/lib/models/session.dart +++ b/app/lib/models/session.dart @@ -1,3 +1,5 @@ +import '../utils/time_format.dart'; + class Session { const Session({ required this.id, @@ -5,6 +7,8 @@ class Session { required this.mentorName, required this.status, required this.waitingCount, + this.createdAt, + this.closedAt, }); factory Session.fromJson(Map json) { @@ -14,6 +18,8 @@ class Session { mentorName: json['mentor_name'] as String, status: json['status'] as String, waitingCount: json['waiting_count'] as int? ?? 0, + createdAt: parseApiDate(json['created_at'] as String?), + closedAt: parseApiDate(json['closed_at'] as String?), ); } @@ -22,6 +28,8 @@ class Session { final String mentorName; final String status; final int waitingCount; + final DateTime? createdAt; + final DateTime? closedAt; bool get isOpen => status == 'OPEN'; } diff --git a/app/lib/screens/join_queue_screen.dart b/app/lib/screens/join_queue_screen.dart index 3894d34..63dc9ee 100644 --- a/app/lib/screens/join_queue_screen.dart +++ b/app/lib/screens/join_queue_screen.dart @@ -2,7 +2,10 @@ import 'package:flutter/material.dart'; import '../models/session.dart'; import '../services/api_service.dart'; +import '../theme/app_theme.dart'; import '../widgets/content_width.dart'; +import '../widgets/state_views.dart'; +import '../widgets/status_badge.dart'; import 'my_doubt_screen.dart'; class JoinQueueScreen extends StatefulWidget { @@ -65,85 +68,158 @@ class _JoinQueueScreenState extends State { } } - String? _required(String? value) { - return value == null || value.trim().isEmpty - ? 'This field is required' - : null; + String? _required(String? value, String message) { + return value == null || value.trim().isEmpty ? message : null; + } + + String? _validateEmail(String? value) { + final email = value?.trim() ?? ''; + if (email.isEmpty) { + return 'Enter your email address'; + } + if (!email.contains('@') || !email.contains('.')) { + return 'Enter a valid email address'; + } + return null; } @override Widget build(BuildContext context) { - final colors = Theme.of(context).colorScheme; - return Scaffold( - appBar: AppBar(title: const Text('Join Queue')), + appBar: AppBar(title: const Text('Join queue')), body: ContentWidth( child: ListView( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(pagePadding), children: [ - Text( - widget.session.title, - style: Theme.of(context).textTheme.titleLarge, + _SessionSummary(session: widget.session), + const SizedBox(height: sectionGap), + _buildFormCard(), + if (_error != null) ...[ + const SizedBox(height: itemGap), + ErrorBanner(message: _error!), + ], + const SizedBox(height: sectionGap), + FilledButton.icon( + onPressed: _submitting ? null : _submit, + icon: _submitting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.login), + label: Text(_submitting ? 'Joining...' : 'Join queue'), ), - Text('Mentor: ${widget.session.mentorName}'), - const SizedBox(height: 16), - Form( - key: _formKey, - child: Column( - children: [ - TextFormField( - controller: _name, - decoration: const InputDecoration( - labelText: 'Name', - border: OutlineInputBorder(), - ), - textInputAction: TextInputAction.next, - validator: _required, - ), - const SizedBox(height: 16), - TextFormField( - controller: _email, - decoration: const InputDecoration( - labelText: 'Email', - border: OutlineInputBorder(), - ), - keyboardType: TextInputType.emailAddress, - textInputAction: TextInputAction.next, - validator: _required, - ), - const SizedBox(height: 16), - TextFormField( - controller: _topic, - decoration: const InputDecoration( - labelText: 'Topic', - border: OutlineInputBorder(), - ), - textInputAction: TextInputAction.next, - validator: _required, - ), - const SizedBox(height: 16), - TextFormField( - controller: _question, - decoration: const InputDecoration( - labelText: 'Question', - border: OutlineInputBorder(), - alignLabelWithHint: true, - ), - minLines: 3, - maxLines: 6, - validator: _required, - ), - ], + ], + ), + ), + ); + } + + Widget _buildFormCard() { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Your details', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 16), + TextFormField( + controller: _name, + decoration: const InputDecoration( + labelText: 'Name', + prefixIcon: Icon(Icons.person_outline), + ), + textInputAction: TextInputAction.next, + validator: (value) => _required(value, 'Enter your name'), + ), + const SizedBox(height: 16), + TextFormField( + controller: _email, + decoration: const InputDecoration( + labelText: 'Email', + prefixIcon: Icon(Icons.mail_outline), + ), + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.next, + validator: _validateEmail, ), - ), - if (_error != null) ...[ const SizedBox(height: 16), - Text(_error!, style: TextStyle(color: colors.error)), + TextFormField( + controller: _topic, + decoration: const InputDecoration( + labelText: 'Topic', + hintText: 'For example: Arrays', + prefixIcon: Icon(Icons.label_outline), + ), + textInputAction: TextInputAction.next, + validator: (value) => _required(value, 'Enter a topic'), + ), + const SizedBox(height: 16), + TextFormField( + controller: _question, + decoration: const InputDecoration( + labelText: 'Question', + hintText: 'Describe what you need help with', + alignLabelWithHint: true, + ), + minLines: 3, + maxLines: 6, + validator: (value) => _required(value, 'Enter your question'), + ), ], - const SizedBox(height: 24), - FilledButton( - onPressed: _submitting ? null : _submit, - child: Text(_submitting ? 'Joining...' : 'Join Queue'), + ), + ), + ), + ); + } +} + +class _SessionSummary extends StatelessWidget { + const _SessionSummary({required this.session}); + + final Session session; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Card( + color: theme.colorScheme.primaryContainer, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + session.title, + style: theme.textTheme.titleLarge?.copyWith( + color: theme.colorScheme.onPrimaryContainer, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 8), + StatusBadge(status: session.status), + ], + ), + const SizedBox(height: 8), + Text( + 'Mentor: ${session.mentorName}', + style: TextStyle(color: theme.colorScheme.onPrimaryContainer), + ), + Text( + '${session.waitingCount} waiting', + style: TextStyle(color: theme.colorScheme.onPrimaryContainer), ), ], ), diff --git a/app/lib/screens/mentor_screen.dart b/app/lib/screens/mentor_screen.dart index a0d581c..e36edd2 100644 --- a/app/lib/screens/mentor_screen.dart +++ b/app/lib/screens/mentor_screen.dart @@ -3,7 +3,15 @@ import 'package:flutter/material.dart'; import '../models/doubt.dart'; import '../models/session.dart'; import '../services/api_service.dart'; +import '../theme/app_theme.dart'; +import '../widgets/confirm_dialog.dart'; import '../widgets/content_width.dart'; +import '../widgets/create_session_card.dart'; +import '../widgets/current_doubt_card.dart'; +import '../widgets/queue_item_card.dart'; +import '../widgets/section_header.dart'; +import '../widgets/state_views.dart'; +import '../widgets/stats_row.dart'; import '../widgets/status_badge.dart'; class MentorScreen extends StatefulWidget { @@ -16,8 +24,6 @@ class MentorScreen extends StatefulWidget { } class _MentorScreenState extends State { - final _title = TextEditingController(); - final _mentorName = TextEditingController(); List _sessions = []; int? _selectedId; List _queue = []; @@ -25,37 +31,52 @@ class _MentorScreenState extends State { Doubt? _current; bool _loading = true; bool _busy = false; - String? _error; + String? _loadError; + String? _actionError; + String? _notice; + + Session? get _selected { + for (final session in _sessions) { + if (session.id == _selectedId) return session; + } + return null; + } @override void initState() { super.initState(); - _run(_loadSessions); + _loadFirstTime(); } - @override - void dispose() { - _title.dispose(); - _mentorName.dispose(); - super.dispose(); + Future _loadFirstTime() async { + setState(() { + _loading = true; + _loadError = null; + }); + try { + await _loadSessions(); + } on ApiException catch (e) { + if (!mounted) return; + setState(() => _loadError = e.message); + } + if (!mounted) return; + setState(() => _loading = false); } Future _run(Future Function() action) async { setState(() { _busy = true; - _error = null; + _actionError = null; + _notice = null; }); try { await action(); } on ApiException catch (e) { if (!mounted) return; - setState(() => _error = e.message); + setState(() => _actionError = e.message); } if (!mounted) return; - setState(() { - _busy = false; - _loading = false; - }); + setState(() => _busy = false); } Future _loadSessions({int? selectId}) async { @@ -83,22 +104,10 @@ class _MentorScreenState extends State { }); } - Future _createSession() async { - final title = _title.text.trim(); - final mentorName = _mentorName.text.trim(); - if (title.isEmpty || mentorName.isEmpty) { - setState( - () => _error = 'Enter a title and your name to create a session.', - ); - return; - } - await _run(() async { - final session = await widget.api.createSession(title, mentorName); - _title.clear(); - _mentorName.clear(); - _current = null; - await _loadSessions(selectId: session.id); - }); + Future _createSession(String title, String mentorName) async { + final session = await widget.api.createSession(title, mentorName); + setState(() => _current = null); + await _loadSessions(selectId: session.id); } Future _selectSession(int? id) async { @@ -120,42 +129,47 @@ class _MentorScreenState extends State { }); } - Future _finishCurrent({required bool solved}) async { + Future _markSolved() async { + final id = _current!.id; + await _run(() async { + await widget.api.markSolved(id); + setState(() => _current = null); + await _loadDetails(); + }); + } + + Future _skip() async { + final confirmed = await confirmAction( + context, + title: 'Skip this doubt?', + message: 'The student will see that their doubt was skipped.', + confirmLabel: 'Skip doubt', + ); + if (!confirmed) return; + final id = _current!.id; await _run(() async { - if (solved) { - await widget.api.markSolved(id); - } else { - await widget.api.markSkipped(id); - } + await widget.api.markSkipped(id); setState(() => _current = null); await _loadDetails(); }); } Future _closeSession() async { - final confirmed = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Close session?'), - content: const Text('Students will not be able to join this session.'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () => Navigator.pop(context, true), - child: const Text('Close session'), - ), - ], - ), + final confirmed = await confirmAction( + context, + title: 'Close this session?', + message: 'Students will not be able to join it any more.', + confirmLabel: 'Close session', ); - if (confirmed != true) return; + if (!confirmed) return; + + final title = _selected?.title ?? 'Session'; await _run(() async { await widget.api.closeSession(_selectedId!); _current = null; await _loadSessions(); + setState(() => _notice = '$title was closed.'); }); } @@ -163,199 +177,174 @@ class _MentorScreenState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text('Mentor'), + title: const Text('Mentor dashboard'), actions: [ IconButton( - onPressed: _busy ? null : () => _run(_loadSessions), + onPressed: _busy || _loading ? null : () => _run(_loadSessions), icon: const Icon(Icons.refresh), tooltip: 'Refresh', ), ], ), - body: ContentWidth( - child: _loading - ? const Center(child: CircularProgressIndicator()) - : ListView( - padding: const EdgeInsets.all(16), - children: [ - if (_busy) const LinearProgressIndicator(), - if (_error != null) _buildError(), - _buildCreateForm(), - const SizedBox(height: 16), - _buildSessionPicker(), - if (_selectedId != null) ..._buildSessionDetails(), - ], - ), - ), + body: ContentWidth(child: _buildBody()), ); } - Widget _buildError() { - final colors = Theme.of(context).colorScheme; + Widget _buildBody() { + if (_loading) { + return const LoadingView(message: 'Loading dashboard...'); + } + if (_loadError != null) { + return ErrorView(message: _loadError!, onRetry: _loadFirstTime); + } - return Card( - color: colors.errorContainer, - child: Padding( - padding: const EdgeInsets.all(16), - child: Text(_error!, style: TextStyle(color: colors.onErrorContainer)), - ), + return ListView( + padding: const EdgeInsets.all(pagePadding), + children: [ + Text( + 'Start a session, then take students from the queue one by one.', + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: itemGap), + SizedBox( + height: 4, + child: _busy ? const LinearProgressIndicator() : null, + ), + if (_actionError != null) ...[ + ErrorBanner(message: _actionError!), + const SizedBox(height: itemGap), + ], + if (_notice != null) ...[ + SuccessBanner(message: _notice!), + const SizedBox(height: itemGap), + ], + const SizedBox(height: itemGap), + CreateSessionCard(onCreate: _createSession), + const SizedBox(height: sectionGap), + ..._buildSessionSection(), + ], ); } - Widget _buildCreateForm() { + List _buildSessionSection() { + final session = _selected; + + return [ + const SectionHeader(title: 'Session'), + const SizedBox(height: itemGap), + if (_sessions.isEmpty) + const Card( + child: EmptyView( + icon: Icons.event_busy, + title: 'No open sessions', + message: 'Start a session above to open a queue for students.', + ), + ) + else + _buildSessionPicker(), + if (session != null) ...[ + const SizedBox(height: sectionGap), + const SectionHeader(title: 'Statistics'), + const SizedBox(height: itemGap), + StatsRow(stats: _stats), + const SizedBox(height: sectionGap), + ..._buildCurrentSection(), + const SizedBox(height: sectionGap), + ..._buildQueueSection(), + ], + ]; + } + + Widget _buildSessionPicker() { + final session = _selected; + return Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'Create a session', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 12), - TextField( - controller: _title, - decoration: const InputDecoration( - labelText: 'Title', - border: OutlineInputBorder(), - ), - ), - const SizedBox(height: 12), - TextField( - controller: _mentorName, - decoration: const InputDecoration( - labelText: 'Mentor name', - border: OutlineInputBorder(), + InputDecorator( + decoration: const InputDecoration(labelText: 'Open session'), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: _selectedId, + isExpanded: true, + hint: const Text('Select a session'), + items: [ + for (final item in _sessions) + DropdownMenuItem( + value: item.id, + child: Text( + '${item.title} (${item.mentorName})', + overflow: TextOverflow.ellipsis, + ), + ), + ], + onChanged: _busy ? null : _selectSession, + ), ), ), - const SizedBox(height: 12), - FilledButton( - onPressed: _busy ? null : _createSession, - child: const Text('Create session'), - ), - ], - ), - ), - ); - } - - Widget _buildSessionPicker() { - if (_sessions.isEmpty) { - return const Text('There are no open sessions. Create one above.'); - } - - return InputDecorator( - decoration: const InputDecoration( - labelText: 'Open session', - border: OutlineInputBorder(), - ), - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: _selectedId, - isExpanded: true, - hint: const Text('Select a session'), - items: [ - for (final session in _sessions) - DropdownMenuItem( - value: session.id, - child: Text( - '${session.title} (${session.mentorName})', - overflow: TextOverflow.ellipsis, - ), + if (session != null) ...[ + const SizedBox(height: 16), + Wrap( + spacing: 12, + runSpacing: 12, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + StatusBadge(status: session.status), + OutlinedButton.icon( + onPressed: _busy ? null : _closeSession, + icon: const Icon(Icons.lock_outline), + label: const Text('Close session'), + ), + ], ), + ], ], - onChanged: _busy ? null : _selectSession, ), ), ); } - List _buildSessionDetails() { - final textTheme = Theme.of(context).textTheme; + List _buildCurrentSection() { + final current = _current; return [ - const SizedBox(height: 16), - _buildStats(), - const SizedBox(height: 16), - if (_current != null) _buildCurrentDoubt(_current!), - FilledButton.icon( - onPressed: _busy || _current != null || _queue.isEmpty - ? null - : _takeNext, - icon: const Icon(Icons.arrow_forward), - label: const Text('Take next'), - ), - const SizedBox(height: 24), - Text('Waiting queue', style: textTheme.titleMedium), - const SizedBox(height: 8), - if (_queue.isEmpty) const Text('No students are waiting.'), - for (var i = 0; i < _queue.length; i++) _buildQueueItem(i, _queue[i]), - const SizedBox(height: 24), - OutlinedButton( - onPressed: _busy ? null : _closeSession, - child: const Text('Close session'), - ), + const SectionHeader(title: 'Current doubt'), + const SizedBox(height: itemGap), + if (current != null) + CurrentDoubtCard( + doubt: current, + busy: _busy, + onSolved: _markSolved, + onSkipped: _skip, + ) + else + _buildNoCurrentDoubt(), ]; } - Widget _buildStats() { - const labels = { - 'WAITING': 'Waiting', - 'IN_PROGRESS': 'In progress', - 'SOLVED': 'Solved', - 'SKIPPED': 'Skipped', - }; + Widget _buildNoCurrentDoubt() { + final hasWaiting = _queue.isNotEmpty; - return Wrap( - spacing: 8, - runSpacing: 8, - children: [ - for (final entry in labels.entries) - Chip(label: Text('${entry.value}: ${_stats[entry.key] ?? 0}')), - ], - ); - } - - Widget _buildCurrentDoubt(Doubt doubt) { return Card( - color: Theme.of(context).colorScheme.secondaryContainer, - margin: const EdgeInsets.only(bottom: 16), child: Padding( padding: const EdgeInsets.all(16), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, children: [ - Wrap( - spacing: 8, - runSpacing: 4, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - Text( - 'Current doubt', - style: Theme.of(context).textTheme.titleMedium, - ), - StatusBadge(status: doubt.status), - ], + const EmptyView( + icon: Icons.support_agent, + title: 'No doubt is currently in progress.', + message: 'Take the next student when you are ready.', ), - const SizedBox(height: 8), - Text('${doubt.studentName} - ${doubt.topic}'), - const SizedBox(height: 4), - Text(doubt.question), - const SizedBox(height: 12), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - FilledButton( - onPressed: _busy ? null : () => _finishCurrent(solved: true), - child: const Text('Mark solved'), - ), - OutlinedButton( - onPressed: _busy ? null : () => _finishCurrent(solved: false), - child: const Text('Skip'), - ), - ], + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: _busy || !hasWaiting ? null : _takeNext, + icon: const Icon(Icons.arrow_forward), + label: const Text('Take next student'), + ), ), ], ), @@ -363,14 +352,31 @@ class _MentorScreenState extends State { ); } - Widget _buildQueueItem(int index, Doubt doubt) { - return Card( - margin: const EdgeInsets.only(bottom: 8), - child: ListTile( - leading: CircleAvatar(child: Text('${index + 1}')), - title: Text('${doubt.studentName} - ${doubt.topic}'), - subtitle: Text(doubt.question), + List _buildQueueSection() { + return [ + SectionHeader( + title: 'Waiting queue', + subtitle: '${_queue.length} waiting', + trailing: TextButton.icon( + onPressed: _busy ? null : () => _run(_loadDetails), + icon: const Icon(Icons.refresh), + label: const Text('Refresh'), + ), ), - ); + const SizedBox(height: itemGap), + if (_queue.isEmpty) + const Card( + child: EmptyView( + icon: Icons.groups_outlined, + title: 'No students are waiting.', + message: 'New questions will appear here when students join.', + ), + ) + else + for (var i = 0; i < _queue.length; i++) ...[ + QueueItemCard(position: i + 1, doubt: _queue[i]), + const SizedBox(height: itemGap), + ], + ]; } } diff --git a/app/lib/screens/my_doubt_screen.dart b/app/lib/screens/my_doubt_screen.dart index b538262..58768c2 100644 --- a/app/lib/screens/my_doubt_screen.dart +++ b/app/lib/screens/my_doubt_screen.dart @@ -4,8 +4,10 @@ import 'package:flutter/material.dart'; import '../models/doubt.dart'; import '../services/api_service.dart'; +import '../theme/app_theme.dart'; import '../widgets/content_width.dart'; -import '../widgets/status_badge.dart'; +import '../widgets/queue_status_card.dart'; +import '../widgets/state_views.dart'; class MyDoubtScreen extends StatefulWidget { const MyDoubtScreen({super.key, required this.api, required this.doubt}); @@ -20,6 +22,7 @@ class MyDoubtScreen extends StatefulWidget { class _MyDoubtScreenState extends State { late Doubt _doubt; Timer? _timer; + bool _refreshing = false; String? _error; @override @@ -36,6 +39,7 @@ class _MyDoubtScreenState extends State { } Future _refresh() async { + setState(() => _refreshing = true); try { final doubt = await widget.api.getDoubt(_doubt.id); if (!mounted) return; @@ -50,33 +54,18 @@ class _MyDoubtScreenState extends State { if (!mounted) return; setState(() => _error = e.message); } - } - - String get _statusText { - switch (_doubt.status) { - case 'WAITING': - return 'You are number ${_doubt.position} in the queue.'; - case 'IN_PROGRESS': - return 'The mentor is looking at your doubt now.'; - case 'SOLVED': - return 'Your doubt was solved.'; - case 'SKIPPED': - return 'The mentor skipped your doubt. You can join the queue again.'; - default: - return ''; - } + if (!mounted) return; + setState(() => _refreshing = false); } @override Widget build(BuildContext context) { - final textTheme = Theme.of(context).textTheme; - return Scaffold( appBar: AppBar( - title: const Text('My Doubt'), + title: const Text('My doubt'), actions: [ IconButton( - onPressed: _refresh, + onPressed: _refreshing ? null : _refresh, icon: const Icon(Icons.refresh), tooltip: 'Refresh', ), @@ -84,34 +73,37 @@ class _MyDoubtScreenState extends State { ), body: ContentWidth( child: ListView( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(pagePadding), children: [ - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - StatusBadge(status: _doubt.status), - const SizedBox(height: 12), - Text(_statusText, style: textTheme.titleMedium), - ], - ), - ), - ), + QueueStatusCard(doubt: _doubt, refreshing: _refreshing), if (_error != null) ...[ - const SizedBox(height: 12), - Text( - _error!, - style: TextStyle(color: Theme.of(context).colorScheme.error), - ), + const SizedBox(height: itemGap), + ErrorBanner(message: _error!, onRetry: _refresh), ], - const SizedBox(height: 16), - Text('Your question', style: textTheme.titleSmall), - const SizedBox(height: 4), - Text(_doubt.topic, style: textTheme.titleMedium), - const SizedBox(height: 4), - Text(_doubt.question), + const SizedBox(height: sectionGap), + _buildDetails(context), + ], + ), + ), + ); + } + + Widget _buildDetails(BuildContext context) { + final theme = Theme.of(context); + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Your doubt', style: theme.textTheme.titleMedium), + const SizedBox(height: 12), + Text('Topic', style: theme.textTheme.labelMedium), + Text(_doubt.topic, style: theme.textTheme.bodyLarge), + const SizedBox(height: 12), + Text('Question', style: theme.textTheme.labelMedium), + Text(_doubt.question, style: theme.textTheme.bodyLarge), ], ), ), diff --git a/app/lib/screens/session_list_screen.dart b/app/lib/screens/session_list_screen.dart index 3ca554f..fb66c37 100644 --- a/app/lib/screens/session_list_screen.dart +++ b/app/lib/screens/session_list_screen.dart @@ -2,7 +2,11 @@ import 'package:flutter/material.dart'; import '../models/session.dart'; import '../services/api_service.dart'; +import '../theme/app_theme.dart'; +import '../utils/session_filter.dart'; import '../widgets/content_width.dart'; +import '../widgets/session_card.dart'; +import '../widgets/state_views.dart'; import 'join_queue_screen.dart'; import 'mentor_screen.dart'; @@ -16,9 +20,13 @@ class SessionListScreen extends StatefulWidget { } class _SessionListScreenState extends State { + final _searchController = TextEditingController(); List _sessions = []; bool _loading = true; + bool _refreshing = false; String? _error; + SessionStatusFilter _statusFilter = SessionStatusFilter.open; + SessionSort _sort = SessionSort.newest; @override void initState() { @@ -26,22 +34,30 @@ class _SessionListScreenState extends State { _load(); } + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + Future _load() async { + setState(() => _refreshing = true); try { - final sessions = await widget.api.getSessions(status: 'OPEN'); + final sessions = await widget.api.getSessions(); if (!mounted) return; setState(() { _sessions = sessions; _error = null; - _loading = false; }); } on ApiException catch (e) { if (!mounted) return; - setState(() { - _error = e.message; - _loading = false; - }); + setState(() => _error = e.message); } + if (!mounted) return; + setState(() { + _loading = false; + _refreshing = false; + }); } Future _openJoin(Session session) async { @@ -62,12 +78,22 @@ class _SessionListScreenState extends State { _load(); } + void _clearSearch() { + _searchController.clear(); + setState(() {}); + } + @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('DoubtQueue'), actions: [ + IconButton( + onPressed: _refreshing ? null : _load, + icon: const Icon(Icons.refresh), + tooltip: 'Refresh', + ), TextButton.icon( onPressed: _openMentor, icon: const Icon(Icons.school_outlined), @@ -76,99 +102,145 @@ class _SessionListScreenState extends State { const SizedBox(width: 8), ], ), - body: ContentWidth(child: _buildBody()), + body: ContentWidth( + child: _loading + ? const LoadingView(message: 'Loading sessions...') + : RefreshIndicator(onRefresh: _load, child: _buildList()), + ), ); } - Widget _buildBody() { - if (_loading) { - return const Center(child: CircularProgressIndicator()); - } - - return RefreshIndicator( - onRefresh: _load, - child: ListView( - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.all(16), - children: [ - Text( - 'Open sessions', - style: Theme.of(context).textTheme.headlineSmall, - ), - const SizedBox(height: 12), - if (_error != null) _ErrorMessage(message: _error!, onRetry: _load), - if (_error == null && _sessions.isEmpty) - const Padding( - padding: EdgeInsets.symmetric(vertical: 32), - child: Text('There are no open sessions right now.'), - ), - for (final session in _sessions) - _SessionCard(session: session, onJoin: () => _openJoin(session)), + Widget _buildList() { + return ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(pagePadding), + children: [ + Text( + 'Join a mentor session and get help with your doubts.', + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: sectionGap), + if (_error != null && _sessions.isEmpty) + ErrorView(message: _error!, onRetry: _load) + else ...[ + if (_error != null) ...[ + ErrorBanner(message: _error!, onRetry: _load), + const SizedBox(height: itemGap), + ], + _buildControls(), + const SizedBox(height: sectionGap), + ..._buildResults(), ], - ), + ], ); } -} -class _SessionCard extends StatelessWidget { - const _SessionCard({required this.session, required this.onJoin}); + Widget _buildControls() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: _searchController, + onChanged: (_) => setState(() {}), + decoration: InputDecoration( + hintText: 'Search by session or mentor name', + prefixIcon: const Icon(Icons.search), + suffixIcon: _searchController.text.isEmpty + ? null + : IconButton( + onPressed: _clearSearch, + icon: const Icon(Icons.close), + tooltip: 'Clear search', + ), + ), + ), + const SizedBox(height: itemGap), + Wrap( + spacing: 16, + runSpacing: itemGap, + children: [_buildStatusFilter(), _buildSortControl()], + ), + ], + ); + } - final Session session; - final VoidCallback onJoin; + Widget _buildStatusFilter() { + return SegmentedButton( + showSelectedIcon: false, + segments: const [ + ButtonSegment(value: SessionStatusFilter.open, label: Text('Open')), + ButtonSegment(value: SessionStatusFilter.closed, label: Text('Closed')), + ButtonSegment(value: SessionStatusFilter.all, label: Text('All')), + ], + selected: {_statusFilter}, + onSelectionChanged: (selection) { + setState(() => _statusFilter = selection.first); + }, + ); + } - @override - Widget build(BuildContext context) { - final textTheme = Theme.of(context).textTheme; - - return Card( - margin: const EdgeInsets.only(bottom: 12), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(session.title, style: textTheme.titleMedium), - const SizedBox(height: 4), - Text('Mentor: ${session.mentorName}'), - Text('${session.waitingCount} waiting'), - const SizedBox(height: 12), - Align( - alignment: Alignment.centerRight, - child: FilledButton( - onPressed: onJoin, - child: const Text('Join Queue'), - ), - ), - ], + Widget _buildSortControl() { + return SegmentedButton( + showSelectedIcon: false, + segments: const [ + ButtonSegment(value: SessionSort.newest, label: Text('Newest')), + ButtonSegment( + value: SessionSort.mostWaiting, + label: Text('Most waiting'), ), - ), + ], + selected: {_sort}, + onSelectionChanged: (selection) { + setState(() => _sort = selection.first); + }, ); } -} -class _ErrorMessage extends StatelessWidget { - const _ErrorMessage({required this.message, required this.onRetry}); + List _buildResults() { + final sessions = filterSessions( + _sessions, + query: _searchController.text, + status: _statusFilter, + sort: _sort, + ); - final String message; - final VoidCallback onRetry; + if (sessions.isEmpty) { + return [_buildEmptyState()]; + } - @override - Widget build(BuildContext context) { - final colors = Theme.of(context).colorScheme; - - return Card( - color: colors.errorContainer, - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(message, style: TextStyle(color: colors.onErrorContainer)), - const SizedBox(height: 8), - TextButton(onPressed: onRetry, child: const Text('Try again')), - ], - ), - ), + return [ + for (final session in sessions) ...[ + SessionCard(session: session, onJoin: () => _openJoin(session)), + const SizedBox(height: itemGap), + ], + ]; + } + + Widget _buildEmptyState() { + if (_searchController.text.trim().isNotEmpty) { + return EmptyView( + icon: Icons.search_off, + title: 'No sessions match your search', + message: 'Try a different session or mentor name.', + actionLabel: 'Clear search', + onAction: _clearSearch, + ); + } + + if (_statusFilter == SessionStatusFilter.closed) { + return const EmptyView( + icon: Icons.lock_outline, + title: 'No closed sessions', + message: 'Sessions that have ended will appear here.', + ); + } + + return EmptyView( + icon: Icons.event_busy, + title: 'No open sessions right now', + message: 'Check again later or ask your mentor to start a session.', + actionLabel: 'Refresh', + onAction: _load, ); } } diff --git a/app/lib/theme/app_theme.dart b/app/lib/theme/app_theme.dart new file mode 100644 index 0000000..4c7f60c --- /dev/null +++ b/app/lib/theme/app_theme.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; + +const double pagePadding = 16; +const double sectionGap = 24; +const double itemGap = 12; +const double cardRadius = 16; +const double maxContentWidth = 720; + +ThemeData buildAppTheme() { + final colors = ColorScheme.fromSeed(seedColor: Colors.teal); + final buttonShape = RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ); + + return ThemeData( + useMaterial3: true, + colorScheme: colors, + appBarTheme: AppBarTheme( + centerTitle: false, + backgroundColor: colors.surface, + scrolledUnderElevation: 0, + titleTextStyle: TextStyle( + color: colors.onSurface, + fontSize: 20, + fontWeight: FontWeight.w600, + ), + ), + cardTheme: CardThemeData( + elevation: 0, + margin: EdgeInsets.zero, + color: colors.surfaceContainerLowest, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(cardRadius), + side: BorderSide(color: colors.outlineVariant), + ), + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + minimumSize: const Size(0, 48), + shape: buttonShape, + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + minimumSize: const Size(0, 48), + shape: buttonShape, + ), + ), + inputDecorationTheme: InputDecorationTheme( + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + ), + ); +} diff --git a/app/lib/utils/session_filter.dart b/app/lib/utils/session_filter.dart new file mode 100644 index 0000000..6710f20 --- /dev/null +++ b/app/lib/utils/session_filter.dart @@ -0,0 +1,61 @@ +import '../models/session.dart'; + +enum SessionStatusFilter { open, closed, all } + +enum SessionSort { newest, mostWaiting } + +List filterSessions( + List sessions, { + String query = '', + SessionStatusFilter status = SessionStatusFilter.open, + SessionSort sort = SessionSort.newest, +}) { + final text = query.trim().toLowerCase(); + + final result = sessions.where((session) { + return _matchesStatus(session, status) && _matchesQuery(session, text); + }).toList(); + + result.sort((a, b) => _compare(a, b, sort, status)); + return result; +} + +bool _matchesStatus(Session session, SessionStatusFilter status) { + switch (status) { + case SessionStatusFilter.open: + return session.isOpen; + case SessionStatusFilter.closed: + return !session.isOpen; + case SessionStatusFilter.all: + return true; + } +} + +bool _matchesQuery(Session session, String text) { + return session.title.toLowerCase().contains(text) || + session.mentorName.toLowerCase().contains(text); +} + +int _compare( + Session a, + Session b, + SessionSort sort, + SessionStatusFilter status, +) { + if (status == SessionStatusFilter.all && a.isOpen != b.isOpen) { + return a.isOpen ? -1 : 1; + } + if (sort == SessionSort.mostWaiting && a.waitingCount != b.waitingCount) { + return b.waitingCount.compareTo(a.waitingCount); + } + return _compareNewest(a, b); +} + +int _compareNewest(Session a, Session b) { + final aTime = a.createdAt; + final bTime = b.createdAt; + if (aTime != null && bTime != null && aTime != bTime) { + return bTime.compareTo(aTime); + } + return b.id.compareTo(a.id); +} diff --git a/app/lib/utils/time_format.dart b/app/lib/utils/time_format.dart new file mode 100644 index 0000000..08146a8 --- /dev/null +++ b/app/lib/utils/time_format.dart @@ -0,0 +1,29 @@ +/// The API sends UTC times as "yyyy-MM-dd HH:mm:ss". +DateTime? parseApiDate(String? value) { + if (value == null || value.isEmpty) { + return null; + } + return DateTime.tryParse('${value.replaceFirst(' ', 'T')}Z'); +} + +String timeAgo(DateTime? time, {DateTime? now}) { + if (time == null) { + return ''; + } + + final difference = (now ?? DateTime.now()).difference(time); + if (difference.inMinutes < 1) { + return 'just now'; + } + if (difference.inMinutes < 60) { + return '${difference.inMinutes} min ago'; + } + if (difference.inHours < 24) { + return _plural(difference.inHours, 'hour'); + } + return _plural(difference.inDays, 'day'); +} + +String _plural(int count, String unit) { + return '$count $unit${count == 1 ? '' : 's'} ago'; +} diff --git a/app/lib/widgets/confirm_dialog.dart b/app/lib/widgets/confirm_dialog.dart new file mode 100644 index 0000000..ef10080 --- /dev/null +++ b/app/lib/widgets/confirm_dialog.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; + +Future confirmAction( + BuildContext context, { + required String title, + required String message, + required String confirmLabel, +}) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(title), + content: Text(message), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: Text(confirmLabel), + ), + ], + ), + ); + return confirmed ?? false; +} diff --git a/app/lib/widgets/content_width.dart b/app/lib/widgets/content_width.dart index ca3f436..2f0fe92 100644 --- a/app/lib/widgets/content_width.dart +++ b/app/lib/widgets/content_width.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import '../theme/app_theme.dart'; + class ContentWidth extends StatelessWidget { const ContentWidth({super.key, required this.child}); @@ -10,7 +12,7 @@ class ContentWidth extends StatelessWidget { return Align( alignment: Alignment.topCenter, child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 600), + constraints: const BoxConstraints(maxWidth: maxContentWidth), child: child, ), ); diff --git a/app/lib/widgets/create_session_card.dart b/app/lib/widgets/create_session_card.dart new file mode 100644 index 0000000..d726bf8 --- /dev/null +++ b/app/lib/widgets/create_session_card.dart @@ -0,0 +1,121 @@ +import 'package:flutter/material.dart'; + +import '../services/api_service.dart'; +import '../theme/app_theme.dart'; +import 'state_views.dart'; + +class CreateSessionCard extends StatefulWidget { + const CreateSessionCard({super.key, required this.onCreate}); + + final Future Function(String title, String mentorName) onCreate; + + @override + State createState() => _CreateSessionCardState(); +} + +class _CreateSessionCardState extends State { + final _formKey = GlobalKey(); + final _title = TextEditingController(); + final _mentorName = TextEditingController(); + bool _creating = false; + String? _error; + String? _success; + + @override + void dispose() { + _title.dispose(); + _mentorName.dispose(); + super.dispose(); + } + + Future _submit() async { + if (!_formKey.currentState!.validate()) return; + + setState(() { + _creating = true; + _error = null; + _success = null; + }); + + try { + await widget.onCreate(_title.text.trim(), _mentorName.text.trim()); + if (!mounted) return; + _formKey.currentState!.reset(); + setState(() => _success = 'Session created. It is selected below.'); + } on ApiException catch (e) { + if (!mounted) return; + setState(() => _error = e.message); + } + if (!mounted) return; + setState(() => _creating = false); + } + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Start a session', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 16), + TextFormField( + controller: _title, + decoration: const InputDecoration( + labelText: 'Session title', + hintText: 'For example: PHP Basics Doubt Session', + prefixIcon: Icon(Icons.title), + ), + textInputAction: TextInputAction.next, + validator: (value) => _required(value, 'Enter a session title'), + ), + const SizedBox(height: 16), + TextFormField( + controller: _mentorName, + decoration: const InputDecoration( + labelText: 'Mentor name', + hintText: 'For example: Priya', + prefixIcon: Icon(Icons.person_outline), + ), + validator: (value) => _required(value, 'Enter your name'), + ), + if (_error != null) ...[ + const SizedBox(height: itemGap), + ErrorBanner(message: _error!), + ], + if (_success != null) ...[ + const SizedBox(height: itemGap), + SuccessBanner(message: _success!), + ], + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: _creating ? null : _submit, + icon: _creating + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.add), + label: Text(_creating ? 'Creating...' : 'Start session'), + ), + ), + ], + ), + ), + ), + ); + } + + String? _required(String? value, String message) { + return value == null || value.trim().isEmpty ? message : null; + } +} diff --git a/app/lib/widgets/current_doubt_card.dart b/app/lib/widgets/current_doubt_card.dart new file mode 100644 index 0000000..a611ab1 --- /dev/null +++ b/app/lib/widgets/current_doubt_card.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; + +import '../models/doubt.dart'; +import '../theme/app_theme.dart'; +import '../utils/time_format.dart'; +import 'status_badge.dart'; + +class CurrentDoubtCard extends StatelessWidget { + const CurrentDoubtCard({ + super.key, + required this.doubt, + required this.busy, + required this.onSolved, + required this.onSkipped, + }); + + final Doubt doubt; + final bool busy; + final VoidCallback onSolved; + final VoidCallback onSkipped; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + + return Card( + color: colors.primaryContainer.withValues(alpha: 0.4), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(cardRadius), + side: BorderSide(color: colors.primary, width: 1.5), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildStatusRow(theme), + const SizedBox(height: 12), + Text( + doubt.studentName, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + Text( + doubt.studentEmail, + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + const SizedBox(height: 12), + Text('Topic', style: theme.textTheme.labelMedium), + Text(doubt.topic, style: theme.textTheme.bodyLarge), + const SizedBox(height: 8), + Text('Question', style: theme.textTheme.labelMedium), + Text(doubt.question, style: theme.textTheme.bodyLarge), + const SizedBox(height: 16), + _buildActions(), + ], + ), + ), + ); + } + + Widget _buildStatusRow(ThemeData theme) { + return Wrap( + spacing: 12, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + StatusBadge(status: doubt.status), + if (doubt.startedAt != null) + Text( + 'Started ${timeAgo(doubt.startedAt)}', + style: theme.textTheme.bodySmall, + ), + ], + ); + } + + Widget _buildActions() { + return Wrap( + spacing: 12, + runSpacing: 12, + children: [ + FilledButton.icon( + onPressed: busy ? null : onSolved, + icon: const Icon(Icons.check), + label: const Text('Mark solved'), + ), + OutlinedButton.icon( + onPressed: busy ? null : onSkipped, + icon: const Icon(Icons.skip_next), + label: const Text('Skip'), + ), + ], + ); + } +} diff --git a/app/lib/widgets/queue_item_card.dart b/app/lib/widgets/queue_item_card.dart new file mode 100644 index 0000000..cbec47f --- /dev/null +++ b/app/lib/widgets/queue_item_card.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; + +import '../models/doubt.dart'; +import '../utils/time_format.dart'; + +class QueueItemCard extends StatelessWidget { + const QueueItemCard({super.key, required this.position, required this.doubt}); + + final int position; + final Doubt doubt; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + + return Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CircleAvatar( + backgroundColor: colors.primaryContainer, + foregroundColor: colors.onPrimaryContainer, + child: Text('$position'), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + doubt.studentName, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + Text( + doubt.topic, + style: theme.textTheme.labelLarge?.copyWith( + color: colors.primary, + ), + ), + const SizedBox(height: 4), + Text( + doubt.question, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + if (doubt.createdAt != null) ...[ + const SizedBox(height: 4), + Text( + 'Joined ${timeAgo(doubt.createdAt)}', + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ], + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/widgets/queue_status_card.dart b/app/lib/widgets/queue_status_card.dart new file mode 100644 index 0000000..601dcd8 --- /dev/null +++ b/app/lib/widgets/queue_status_card.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; + +import '../models/doubt.dart'; +import 'status_badge.dart'; + +class QueueStatusCard extends StatelessWidget { + const QueueStatusCard({ + super.key, + required this.doubt, + required this.refreshing, + }); + + final Doubt doubt; + final bool refreshing; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + children: [ + _buildHeader(context), + const SizedBox(height: 20), + if (doubt.isWaiting) + _buildWaiting(context) + else + _buildOtherStatus(context), + ], + ), + ), + ); + } + + Widget _buildHeader(BuildContext context) { + return Row( + children: [ + StatusBadge(status: doubt.status), + const Spacer(), + if (refreshing) ...[ + const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: 8), + Text('Updating', style: Theme.of(context).textTheme.labelMedium), + ], + ], + ); + } + + Widget _buildWaiting(BuildContext context) { + final theme = Theme.of(context); + + return Column( + children: [ + Text('Your position', style: theme.textTheme.titleMedium), + Text( + '${doubt.position ?? '-'}', + style: theme.textTheme.displayLarge?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + Text( + 'Students ahead of you: ${doubt.studentsAhead}', + style: theme.textTheme.bodyLarge, + ), + const SizedBox(height: 12), + Text( + 'You are currently in the queue.', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + ], + ); + } + + Widget _buildOtherStatus(BuildContext context) { + final theme = Theme.of(context); + final style = statusStyleFor(doubt.status); + + return Column( + children: [ + Icon(style.icon, size: 56, color: style.accent), + const SizedBox(height: 12), + Text( + _message(doubt.status), + style: theme.textTheme.titleMedium, + textAlign: TextAlign.center, + ), + ], + ); + } + + String _message(String status) { + switch (status) { + case 'IN_PROGRESS': + return "It's your turn. The mentor is ready for your doubt."; + case 'SOLVED': + return 'Your doubt has been solved.'; + case 'SKIPPED': + return 'Your doubt was skipped.'; + default: + return ''; + } + } +} diff --git a/app/lib/widgets/section_header.dart b/app/lib/widgets/section_header.dart new file mode 100644 index 0000000..9f0d755 --- /dev/null +++ b/app/lib/widgets/section_header.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; + +class SectionHeader extends StatelessWidget { + const SectionHeader({ + super.key, + required this.title, + this.subtitle, + this.trailing, + }); + + final String title; + final String? subtitle; + final Widget? trailing; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + if (subtitle != null) + Text( + subtitle!, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ?trailing, + ], + ); + } +} diff --git a/app/lib/widgets/session_card.dart b/app/lib/widgets/session_card.dart new file mode 100644 index 0000000..b8a8acc --- /dev/null +++ b/app/lib/widgets/session_card.dart @@ -0,0 +1,119 @@ +import 'package:flutter/material.dart'; + +import '../models/session.dart'; +import '../theme/app_theme.dart'; +import '../utils/time_format.dart'; +import 'status_badge.dart'; + +class SessionCard extends StatelessWidget { + const SessionCard({super.key, required this.session, required this.onJoin}); + + final Session session; + final VoidCallback onJoin; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + + return Card( + color: session.isOpen ? null : colors.surfaceContainerLow, + shape: session.isOpen + ? RoundedRectangleBorder( + borderRadius: BorderRadius.circular(cardRadius), + side: BorderSide(color: colors.primary, width: 1.5), + ) + : null, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildTitleRow(context), + const SizedBox(height: 12), + _InfoRow( + icon: Icons.person_outline, + text: 'Mentor: ${session.mentorName}', + ), + _InfoRow( + icon: Icons.groups_outlined, + text: _waitingText(session.waitingCount), + ), + if (session.createdAt != null) + _InfoRow( + icon: Icons.schedule, + text: 'Started ${timeAgo(session.createdAt)}', + ), + const SizedBox(height: 16), + _buildAction(context), + ], + ), + ), + ); + } + + Widget _buildTitleRow(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + session.title, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w600), + ), + ), + const SizedBox(width: 8), + StatusBadge(status: session.status), + ], + ); + } + + Widget _buildAction(BuildContext context) { + if (session.isOpen) { + return SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: onJoin, + icon: const Icon(Icons.login), + label: const Text('Join queue'), + ), + ); + } + + return Text( + 'This session has ended.', + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), + ); + } + + String _waitingText(int count) { + if (count == 0) { + return 'No students waiting'; + } + return count == 1 ? '1 student waiting' : '$count students waiting'; + } +} + +class _InfoRow extends StatelessWidget { + const _InfoRow({required this.icon, required this.text}); + + final IconData icon; + final String text; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + + return Padding( + padding: const EdgeInsets.only(top: 4), + child: Row( + children: [ + Icon(icon, size: 18, color: colors.onSurfaceVariant), + const SizedBox(width: 8), + Expanded(child: Text(text)), + ], + ), + ); + } +} diff --git a/app/lib/widgets/state_views.dart b/app/lib/widgets/state_views.dart new file mode 100644 index 0000000..3964634 --- /dev/null +++ b/app/lib/widgets/state_views.dart @@ -0,0 +1,180 @@ +import 'package:flutter/material.dart'; + +class LoadingView extends StatelessWidget { + const LoadingView({super.key, required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 48), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 16), + Text(message, style: Theme.of(context).textTheme.bodyMedium), + ], + ), + ), + ); + } +} + +class EmptyView extends StatelessWidget { + const EmptyView({ + super.key, + required this.icon, + required this.title, + required this.message, + this.actionLabel, + this.onAction, + }); + + final IconData icon; + final String title; + final String message; + final String? actionLabel; + final VoidCallback? onAction; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 48, color: theme.colorScheme.outline), + const SizedBox(height: 12), + Text( + title, + style: theme.textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 4), + Text( + message, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + if (actionLabel != null) ...[ + const SizedBox(height: 16), + OutlinedButton(onPressed: onAction, child: Text(actionLabel!)), + ], + ], + ), + ), + ); + } +} + +class ErrorView extends StatelessWidget { + const ErrorView({super.key, required this.message, required this.onRetry}); + + final String message; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline, size: 48, color: theme.colorScheme.error), + const SizedBox(height: 12), + Text( + 'Could not load this page', + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: 4), + Text( + message, + style: theme.textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh), + label: const Text('Try again'), + ), + ], + ), + ), + ); + } +} + +class ErrorBanner extends StatelessWidget { + const ErrorBanner({super.key, required this.message, this.onRetry}); + + final String message; + final VoidCallback? onRetry; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + + return Card( + color: colors.errorContainer, + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Icon(Icons.error_outline, color: colors.onErrorContainer), + const SizedBox(width: 12), + Expanded( + child: Text( + message, + style: TextStyle(color: colors.onErrorContainer), + ), + ), + if (onRetry != null) + TextButton(onPressed: onRetry, child: const Text('Try again')), + ], + ), + ), + ); + } +} + +class SuccessBanner extends StatelessWidget { + const SuccessBanner({super.key, required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + + return Card( + color: colors.primaryContainer, + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Icon(Icons.check_circle_outline, color: colors.onPrimaryContainer), + const SizedBox(width: 12), + Expanded( + child: Text( + message, + style: TextStyle(color: colors.onPrimaryContainer), + ), + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/widgets/stats_row.dart b/app/lib/widgets/stats_row.dart new file mode 100644 index 0000000..f2a92bc --- /dev/null +++ b/app/lib/widgets/stats_row.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; + +import 'status_badge.dart'; + +class StatsRow extends StatelessWidget { + const StatsRow({super.key, required this.stats}); + + final Map stats; + + static const _statuses = ['WAITING', 'IN_PROGRESS', 'SOLVED', 'SKIPPED']; + static const double _gap = 12; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final columns = constraints.maxWidth >= 480 ? 4 : 2; + final cardWidth = + (constraints.maxWidth - _gap * (columns - 1)) / columns; + + return Wrap( + spacing: _gap, + runSpacing: _gap, + children: [ + for (final status in _statuses) + SizedBox( + width: cardWidth, + child: _StatCard(status: status, count: stats[status] ?? 0), + ), + ], + ); + }, + ); + } +} + +class _StatCard extends StatelessWidget { + const _StatCard({required this.status, required this.count}); + + final String status; + final int count; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final style = statusStyleFor(status); + + return Card( + color: style.background, + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(style.icon, size: 18, color: style.text), + const SizedBox(width: 6), + Expanded( + child: Text( + style.label, + style: theme.textTheme.labelLarge?.copyWith( + color: style.text, + ), + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + '$count', + style: theme.textTheme.headlineMedium?.copyWith( + color: style.text, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/widgets/status_badge.dart b/app/lib/widgets/status_badge.dart index 288fe2f..00f22bc 100644 --- a/app/lib/widgets/status_badge.dart +++ b/app/lib/widgets/status_badge.dart @@ -1,5 +1,82 @@ import 'package:flutter/material.dart'; +class StatusStyle { + const StatusStyle({ + required this.label, + required this.icon, + required this.accent, + required this.background, + required this.text, + }); + + final String label; + final IconData icon; + final Color accent; + final Color background; + final Color text; +} + +StatusStyle statusStyleFor(String status) { + switch (status) { + case 'WAITING': + return StatusStyle( + label: 'Waiting', + icon: Icons.hourglass_top, + accent: Colors.blueGrey.shade600, + background: Colors.blueGrey.shade50, + text: Colors.blueGrey.shade800, + ); + case 'IN_PROGRESS': + return StatusStyle( + label: 'In progress', + icon: Icons.play_circle_outline, + accent: Colors.amber.shade800, + background: Colors.amber.shade50, + text: const Color(0xFF6B4500), + ); + case 'SOLVED': + return StatusStyle( + label: 'Solved', + icon: Icons.check_circle_outline, + accent: Colors.green.shade600, + background: Colors.green.shade50, + text: Colors.green.shade900, + ); + case 'SKIPPED': + return StatusStyle( + label: 'Skipped', + icon: Icons.skip_next, + accent: Colors.grey.shade600, + background: Colors.grey.shade200, + text: Colors.grey.shade800, + ); + case 'CLOSED': + return StatusStyle( + label: 'Closed', + icon: Icons.lock_outline, + accent: Colors.red.shade600, + background: Colors.red.shade50, + text: Colors.red.shade900, + ); + case 'OPEN': + return StatusStyle( + label: 'Open', + icon: Icons.radio_button_checked, + accent: Colors.teal.shade600, + background: Colors.teal.shade50, + text: Colors.teal.shade900, + ); + default: + return StatusStyle( + label: status, + icon: Icons.help_outline, + accent: Colors.grey.shade600, + background: Colors.grey.shade200, + text: Colors.grey.shade800, + ); + } +} + class StatusBadge extends StatelessWidget { const StatusBadge({super.key, required this.status}); @@ -7,48 +84,26 @@ class StatusBadge extends StatelessWidget { @override Widget build(BuildContext context) { - final style = _styleFor(status); + final style = statusStyleFor(status); return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( - color: style.color.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(12), + color: style.background, + borderRadius: BorderRadius.circular(20), ), - child: Text( - style.label, - style: Theme.of(context).textTheme.labelMedium - ?.copyWith(color: style.textColor, fontWeight: FontWeight.w600), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(style.icon, size: 16, color: style.text), + const SizedBox(width: 6), + Text( + style.label, + style: Theme.of(context).textTheme.labelMedium + ?.copyWith(color: style.text, fontWeight: FontWeight.w600), + ), + ], ), ); } - - _BadgeStyle _styleFor(String status) { - switch (status) { - case 'WAITING': - return _BadgeStyle( - 'Waiting', - Colors.blueGrey, - Colors.blueGrey.shade700, - ); - case 'IN_PROGRESS': - return _BadgeStyle('In progress', Colors.amber, Colors.amber.shade900); - case 'SOLVED': - return _BadgeStyle('Solved', Colors.green, Colors.green.shade800); - case 'SKIPPED': - return _BadgeStyle('Skipped', Colors.grey, Colors.grey.shade700); - case 'CLOSED': - return _BadgeStyle('Closed', Colors.red, Colors.red.shade800); - default: - return _BadgeStyle(status, Colors.grey, Colors.grey.shade700); - } - } -} - -class _BadgeStyle { - const _BadgeStyle(this.label, this.color, this.textColor); - - final String label; - final Color color; - final Color textColor; } diff --git a/app/test/api_service_test.dart b/app/test/api_service_test.dart index 8210d00..665b815 100644 --- a/app/test/api_service_test.dart +++ b/app/test/api_service_test.dart @@ -33,6 +33,34 @@ void main() { expect(sessions.first.waitingCount, 5); }); + test('a network failure throws a friendly message', () async { + final client = MockClient((request) async { + throw http.ClientException('connection refused'); + }); + final api = ApiService(client, baseUrl: baseUrl); + + expect( + api.getSessions(), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Could not reach the server'), + ), + ), + ); + }); + + test('getSessions without a status asks for every session', () async { + final client = MockClient((request) async { + expect(request.url.toString(), '$baseUrl/api/sessions'); + return http.Response('[]', 200); + }); + final api = ApiService(client, baseUrl: baseUrl); + + expect(await api.getSessions(), isEmpty); + }); + test('a 409 response throws an exception with the API message', () async { final client = MockClient((request) async { return http.Response( diff --git a/app/test/fake_backend.dart b/app/test/fake_backend.dart new file mode 100644 index 0000000..ed0a0cd --- /dev/null +++ b/app/test/fake_backend.dart @@ -0,0 +1,169 @@ +import 'dart:convert'; + +import 'package:doubtqueue/services/api_service.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +/// A small in-memory copy of the DoubtQueue API for widget tests. +class FakeBackend { + FakeBackend({List>? sessions}) + : sessions = sessions ?? [openSession(1, 'PHP Basics Doubt Session')]; + + final List> sessions; + final List> doubts = []; + bool failRequests = false; + + static Map openSession( + int id, + String title, { + String mentor = 'Priya', + String createdAt = '2026-01-01 10:00:00', + }) { + return { + 'id': id, + 'title': title, + 'mentor_name': mentor, + 'status': 'OPEN', + 'created_at': createdAt, + 'closed_at': null, + }; + } + + late final MockClient client = MockClient(_handle); + + ApiService get api => ApiService(client, baseUrl: 'http://test.local'); + + Map addDoubt( + int sessionId, + String name, { + String status = 'WAITING', + }) { + final doubt = { + 'id': doubts.length + 1, + 'session_id': sessionId, + 'student_name': name, + 'student_email': '${name.toLowerCase()}@example.com', + 'topic': 'Arrays', + 'question': 'How do loops work for $name?', + 'status': status, + 'created_at': '2026-01-01 10:05:00', + 'started_at': null, + 'finished_at': null, + }; + doubts.add(doubt); + return doubt; + } + + List> _doubtsOf(int sessionId, String status) { + return doubts + .where((d) => d['session_id'] == sessionId && d['status'] == status) + .toList(); + } + + Map _withPosition(Map doubt) { + final waiting = _doubtsOf(doubt['session_id'] as int, 'WAITING'); + final isWaiting = doubt['status'] == 'WAITING'; + return { + ...doubt, + 'position': isWaiting ? waiting.indexOf(doubt) + 1 : null, + }; + } + + Map _sessionJson(Map session) { + final waiting = _doubtsOf(session['id'] as int, 'WAITING').length; + return {...session, 'waiting_count': waiting}; + } + + http.Response _json(Object body, [int status = 200]) { + return http.Response(jsonEncode(body), status); + } + + Future _handle(http.Request request) async { + if (failRequests) { + return _json({'status': 500, 'message': 'Something went wrong'}, 500); + } + + final path = request.url.path; + final method = request.method; + + if (path == '/api/sessions' && method == 'GET') { + final status = request.url.queryParameters['status']; + final list = sessions.where( + (s) => status == null || s['status'] == status, + ); + return _json(list.map(_sessionJson).toList()); + } + if (path == '/api/sessions' && method == 'POST') { + final body = jsonDecode(request.body) as Map; + final session = { + ...openSession(sessions.length + 1, body['title'] as String), + 'mentor_name': body['mentor_name'], + }; + sessions.add(session); + return _json(_sessionJson(session), 201); + } + + final sessionMatch = RegExp( + r'^/api/sessions/(\d+)/(doubts|queue|stats|next|close)$', + ).firstMatch(path); + if (sessionMatch != null) { + return _handleSession( + int.parse(sessionMatch.group(1)!), + sessionMatch.group(2)!, + request, + ); + } + + final doubtMatch = RegExp(r'^/api/doubts/(\d+)(/solved|/skipped)?$') + .firstMatch(path); + if (doubtMatch != null) { + return _handleDoubt(int.parse(doubtMatch.group(1)!), doubtMatch.group(2)); + } + + return _json({'status': 404, 'message': 'Route not found'}, 404); + } + + http.Response _handleSession(int id, String action, http.Request request) { + final session = sessions.firstWhere((s) => s['id'] == id); + + switch (action) { + case 'doubts': + if (session['status'] != 'OPEN') { + return _json({ + 'status': 409, + 'message': 'Session with id $id is closed', + }, 409); + } + final body = jsonDecode(request.body) as Map; + final doubt = addDoubt(id, body['student_name'] as String); + doubt['topic'] = body['topic']; + doubt['question'] = body['question']; + return _json(_withPosition(doubt), 201); + case 'queue': + return _json(_doubtsOf(id, 'WAITING')); + case 'stats': + return _json({ + for (final s in ['WAITING', 'IN_PROGRESS', 'SOLVED', 'SKIPPED']) + s: _doubtsOf(id, s).length, + }); + case 'next': + final waiting = _doubtsOf(id, 'WAITING'); + if (waiting.isEmpty) { + return _json({'status': 404, 'message': 'No waiting doubts'}, 404); + } + waiting.first['status'] = 'IN_PROGRESS'; + waiting.first['started_at'] = '2026-01-01 10:10:00'; + return _json(_withPosition(waiting.first)); + default: + session['status'] = 'CLOSED'; + return _json(_sessionJson(session)); + } + } + + http.Response _handleDoubt(int id, String? action) { + final doubt = doubts.firstWhere((d) => d['id'] == id); + if (action == '/solved') doubt['status'] = 'SOLVED'; + if (action == '/skipped') doubt['status'] = 'SKIPPED'; + return _json(_withPosition(doubt)); + } +} diff --git a/app/test/join_queue_screen_test.dart b/app/test/join_queue_screen_test.dart new file mode 100644 index 0000000..dd8ad96 --- /dev/null +++ b/app/test/join_queue_screen_test.dart @@ -0,0 +1,95 @@ +import 'package:doubtqueue/models/session.dart'; +import 'package:doubtqueue/screens/join_queue_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'fake_backend.dart'; + +Future pumpJoin(WidgetTester tester, {bool closed = false}) async { + tester.view.physicalSize = const Size(800, 2000); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + final backend = FakeBackend(); + if (closed) { + backend.sessions.first['status'] = 'CLOSED'; + } + final session = Session.fromJson({ + ...backend.sessions.first, + 'waiting_count': 0, + }); + + await tester.pumpWidget( + MaterialApp( + home: JoinQueueScreen(api: backend.api, session: session), + ), + ); + return backend; +} + +Future fillForm(WidgetTester tester) async { + final fields = find.byType(TextFormField); + await tester.enterText(fields.at(0), 'Asha'); + await tester.enterText(fields.at(1), 'asha@example.com'); + await tester.enterText(fields.at(2), 'Arrays'); + await tester.enterText(fields.at(3), 'How do loops work?'); +} + +Future submit(WidgetTester tester) async { + await tester.ensureVisible(find.byIcon(Icons.login)); + await tester.tap(find.byIcon(Icons.login)); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets('shows validation messages for an empty form', (tester) async { + await pumpJoin(tester); + + await submit(tester); + + expect(find.text('Enter your name'), findsOneWidget); + expect(find.text('Enter your email address'), findsOneWidget); + expect(find.text('Enter a topic'), findsOneWidget); + expect(find.text('Enter your question'), findsOneWidget); + }); + + testWidgets('rejects an email without an at sign', (tester) async { + await pumpJoin(tester); + await fillForm(tester); + await tester.enterText( + find.byType(TextFormField).at(1), + 'asha.example.com', + ); + + await submit(tester); + + expect(find.text('Enter a valid email address'), findsOneWidget); + }); + + testWidgets('joining opens the my doubt screen with the queue position', ( + tester, + ) async { + await pumpJoin(tester); + await fillForm(tester); + + await submit(tester); + + expect(find.text('My doubt'), findsOneWidget); + expect(find.text('Your position'), findsOneWidget); + expect(find.text('Students ahead of you: 0'), findsOneWidget); + + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('shows the API message when the session is closed', ( + tester, + ) async { + await pumpJoin(tester, closed: true); + await fillForm(tester); + + await submit(tester); + + expect(find.text('Session with id 1 is closed'), findsOneWidget); + expect(find.text('Join queue'), findsWidgets); + }); +} diff --git a/app/test/mentor_screen_test.dart b/app/test/mentor_screen_test.dart new file mode 100644 index 0000000..279cc8e --- /dev/null +++ b/app/test/mentor_screen_test.dart @@ -0,0 +1,196 @@ +import 'package:doubtqueue/screens/mentor_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'fake_backend.dart'; + +FakeBackend backendWithQueue({int waiting = 2}) { + final backend = FakeBackend(); + for (final name in ['Asha', 'Ben', 'Chitra'].take(waiting)) { + backend.addDoubt(1, name); + } + return backend; +} + +Future pumpMentor(WidgetTester tester, FakeBackend backend) async { + tester.view.physicalSize = const Size(800, 3000); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + await tester.pumpWidget(MaterialApp(home: MentorScreen(api: backend.api))); + await tester.pumpAndSettle(); +} + +Future selectSession(WidgetTester tester) async { + await tester.tap(find.byType(DropdownButton)); + await tester.pumpAndSettle(); + await tester.tap( + find.textContaining('PHP Basics Doubt Session (Priya)').last, + ); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets('shows an empty state and the start form without sessions', ( + tester, + ) async { + await pumpMentor(tester, FakeBackend(sessions: [])); + + expect(find.text('Mentor dashboard'), findsOneWidget); + expect(find.text('Start a session'), findsOneWidget); + expect(find.text('No open sessions'), findsOneWidget); + expect(find.text('Statistics'), findsNothing); + }); + + testWidgets('shows an error state when the dashboard cannot load', ( + tester, + ) async { + final backend = FakeBackend()..failRequests = true; + await pumpMentor(tester, backend); + + expect(find.text('Could not load this page'), findsOneWidget); + + backend.failRequests = false; + await tester.tap(find.text('Try again')); + await tester.pumpAndSettle(); + + expect(find.text('Start a session'), findsOneWidget); + }); + + testWidgets('shows statistics and the waiting queue for a session', ( + tester, + ) async { + final backend = backendWithQueue(); + backend.addDoubt(1, 'Dev', status: 'SOLVED'); + await pumpMentor(tester, backend); + + await selectSession(tester); + + expect(find.text('Statistics'), findsOneWidget); + expect(find.text('Waiting'), findsWidgets); + expect(find.text('2 waiting'), findsOneWidget); + expect(find.text('Asha'), findsOneWidget); + expect(find.text('Ben'), findsOneWidget); + expect(find.text('1'), findsWidgets); + expect(find.text('No doubt is currently in progress.'), findsOneWidget); + }); + + testWidgets('shows an empty queue state', (tester) async { + await pumpMentor(tester, backendWithQueue(waiting: 0)); + + await selectSession(tester); + + expect(find.text('No students are waiting.'), findsOneWidget); + final button = tester.widget( + find.ancestor( + of: find.text('Take next student'), + matching: find.byWidgetPredicate((widget) => widget is FilledButton), + ), + ); + expect(button.onPressed, isNull); + }); + + testWidgets('take next shows the current doubt and mark solved clears it', ( + tester, + ) async { + final backend = backendWithQueue(); + await pumpMentor(tester, backend); + await selectSession(tester); + + await tester.tap(find.text('Take next student')); + await tester.pumpAndSettle(); + + expect(find.text('asha@example.com'), findsOneWidget); + expect(find.text('Mark solved'), findsOneWidget); + expect(find.text('Take next student'), findsNothing); + expect(backend.doubts.first['status'], 'IN_PROGRESS'); + expect(find.text('1 waiting'), findsOneWidget); + + await tester.tap(find.text('Mark solved')); + await tester.pumpAndSettle(); + + expect(backend.doubts.first['status'], 'SOLVED'); + expect(find.text('No doubt is currently in progress.'), findsOneWidget); + }); + + testWidgets('skipping asks for confirmation first', (tester) async { + final backend = backendWithQueue(); + await pumpMentor(tester, backend); + await selectSession(tester); + await tester.tap(find.text('Take next student')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Skip')); + await tester.pumpAndSettle(); + expect(find.text('Skip this doubt?'), findsOneWidget); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(backend.doubts.first['status'], 'IN_PROGRESS'); + + await tester.tap(find.text('Skip')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Skip doubt')); + await tester.pumpAndSettle(); + expect(backend.doubts.first['status'], 'SKIPPED'); + }); + + testWidgets('closing a session asks for confirmation first', (tester) async { + final backend = backendWithQueue(); + await pumpMentor(tester, backend); + await selectSession(tester); + + await tester.tap(find.text('Close session')); + await tester.pumpAndSettle(); + expect(find.text('Close this session?'), findsOneWidget); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(backend.sessions.first['status'], 'OPEN'); + + await tester.tap(find.text('Close session')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Close session')); + await tester.pumpAndSettle(); + + expect(backend.sessions.first['status'], 'CLOSED'); + expect(find.text('PHP Basics Doubt Session was closed.'), findsOneWidget); + expect(find.text('No open sessions'), findsOneWidget); + }); + + testWidgets('creating a session validates, shows feedback and selects it', ( + tester, + ) async { + final backend = FakeBackend(sessions: []); + await pumpMentor(tester, backend); + + await tester.tap(find.text('Start session')); + await tester.pumpAndSettle(); + expect(find.text('Enter a session title'), findsOneWidget); + expect(find.text('Enter your name'), findsOneWidget); + + await tester.enterText(find.byType(TextFormField).at(0), 'Dart Basics'); + await tester.enterText(find.byType(TextFormField).at(1), 'Meera'); + await tester.tap(find.text('Start session')); + await tester.pumpAndSettle(); + + expect(backend.sessions, hasLength(1)); + expect(find.text('Session created. It is selected below.'), findsOneWidget); + expect(find.text('Statistics'), findsOneWidget); + expect(find.text('Dart Basics (Meera)'), findsOneWidget); + }); + + testWidgets('the queue refresh button loads new students', (tester) async { + final backend = backendWithQueue(waiting: 1); + await pumpMentor(tester, backend); + await selectSession(tester); + expect(find.text('Ben'), findsNothing); + + backend.addDoubt(1, 'Ben'); + await tester.tap(find.text('Refresh')); + await tester.pumpAndSettle(); + + expect(find.text('Ben'), findsOneWidget); + expect(find.text('2 waiting'), findsOneWidget); + }); +} diff --git a/app/test/models_test.dart b/app/test/models_test.dart index 4cd6730..b132093 100644 --- a/app/test/models_test.dart +++ b/app/test/models_test.dart @@ -19,6 +19,21 @@ void main() { expect(session.mentorName, 'Priya'); expect(session.isOpen, isTrue); expect(session.waitingCount, 5); + expect(session.createdAt, DateTime.utc(2026, 1, 1, 10)); + expect(session.closedAt, isNull); + }); + + test('Session.fromJson works without dates', () { + final session = Session.fromJson({ + 'id': 4, + 'title': 'Old session', + 'mentor_name': 'Arjun', + 'status': 'CLOSED', + }); + + expect(session.isOpen, isFalse); + expect(session.waitingCount, 0); + expect(session.createdAt, isNull); }); test('Doubt.fromJson reads a waiting doubt with a position', () { @@ -30,14 +45,20 @@ void main() { 'topic': 'Arrays', 'question': 'How do I loop over an array?', 'status': 'WAITING', - 'position': 2, + 'position': 3, + 'created_at': '2026-01-01 10:05:00', + 'started_at': null, }); expect(doubt.id, 8); expect(doubt.sessionId, 3); expect(doubt.studentName, 'Asha'); + expect(doubt.studentEmail, 'asha@example.com'); expect(doubt.isWaiting, isTrue); - expect(doubt.position, 2); + expect(doubt.position, 3); + expect(doubt.studentsAhead, 2); + expect(doubt.createdAt, DateTime.utc(2026, 1, 1, 10, 5)); + expect(doubt.startedAt, isNull); }); test('Doubt.fromJson accepts a null position', () { @@ -50,9 +71,12 @@ void main() { 'question': 'What is a prepared statement?', 'status': 'SOLVED', 'position': null, + 'started_at': '2026-01-01 10:10:00', }); expect(doubt.position, isNull); + expect(doubt.studentsAhead, 0); expect(doubt.isFinished, isTrue); + expect(doubt.startedAt, DateTime.utc(2026, 1, 1, 10, 10)); }); } diff --git a/app/test/my_doubt_screen_test.dart b/app/test/my_doubt_screen_test.dart new file mode 100644 index 0000000..2ff4f17 --- /dev/null +++ b/app/test/my_doubt_screen_test.dart @@ -0,0 +1,118 @@ +import 'package:doubtqueue/models/doubt.dart'; +import 'package:doubtqueue/screens/my_doubt_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'fake_backend.dart'; + +Future pumpMyDoubt(WidgetTester tester, {int ahead = 2}) async { + final backend = FakeBackend(); + for (var i = 0; i < ahead; i++) { + backend.addDoubt(1, 'Student$i'); + } + final mine = backend.addDoubt(1, 'Asha'); + final doubt = Doubt.fromJson({...mine, 'position': ahead + 1}); + + await tester.pumpWidget( + MaterialApp( + home: MyDoubtScreen(api: backend.api, doubt: doubt), + ), + ); + return backend; +} + +void main() { + testWidgets('shows position, students ahead and the doubt details', ( + tester, + ) async { + await pumpMyDoubt(tester); + + expect(find.text('Your position'), findsOneWidget); + expect(find.text('3'), findsOneWidget); + expect(find.text('Students ahead of you: 2'), findsOneWidget); + expect(find.text('You are currently in the queue.'), findsOneWidget); + expect(find.text('Waiting'), findsOneWidget); + expect(find.text('Arrays'), findsOneWidget); + expect(find.text('How do loops work for Asha?'), findsOneWidget); + + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('refreshes every 10 seconds and updates the position', ( + tester, + ) async { + final backend = await pumpMyDoubt(tester); + + backend.doubts.first['status'] = 'SOLVED'; + await tester.pump(const Duration(seconds: 10)); + await tester.pump(); + + expect(find.text('2'), findsOneWidget); + expect(find.text('Students ahead of you: 1'), findsOneWidget); + + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('shows the turn message when the mentor takes the doubt', ( + tester, + ) async { + final backend = await pumpMyDoubt(tester, ahead: 0); + + backend.doubts.last['status'] = 'IN_PROGRESS'; + await tester.pump(const Duration(seconds: 10)); + await tester.pump(); + + expect( + find.text("It's your turn. The mentor is ready for your doubt."), + findsOneWidget, + ); + expect(find.text('In progress'), findsOneWidget); + expect(find.text('Your position'), findsNothing); + + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('solved doubts show a message and stop refreshing', ( + tester, + ) async { + final backend = await pumpMyDoubt(tester, ahead: 0); + + backend.doubts.last['status'] = 'SOLVED'; + await tester.pump(const Duration(seconds: 10)); + await tester.pump(); + + expect(find.text('Your doubt has been solved.'), findsOneWidget); + expect(find.text('Students ahead of you: 0'), findsNothing); + + backend.failRequests = true; + await tester.pump(const Duration(seconds: 30)); + expect(find.text('Your doubt has been solved.'), findsOneWidget); + expect(find.text('Something went wrong'), findsNothing); + }); + + testWidgets('skipped doubts show the skipped message', (tester) async { + final backend = await pumpMyDoubt(tester, ahead: 0); + + backend.doubts.last['status'] = 'SKIPPED'; + await tester.pump(const Duration(seconds: 10)); + await tester.pump(); + + expect(find.text('Your doubt was skipped.'), findsOneWidget); + expect(find.text('Skipped'), findsOneWidget); + }); + + testWidgets('keeps the last status and shows an error when refresh fails', ( + tester, + ) async { + final backend = await pumpMyDoubt(tester); + + backend.failRequests = true; + await tester.pump(const Duration(seconds: 10)); + await tester.pump(); + + expect(find.text('Something went wrong'), findsOneWidget); + expect(find.text('Students ahead of you: 2'), findsOneWidget); + + await tester.pumpWidget(const SizedBox()); + }); +} diff --git a/app/test/responsive_test.dart b/app/test/responsive_test.dart new file mode 100644 index 0000000..10f8ede --- /dev/null +++ b/app/test/responsive_test.dart @@ -0,0 +1,109 @@ +import 'package:doubtqueue/models/doubt.dart'; +import 'package:doubtqueue/models/session.dart'; +import 'package:doubtqueue/screens/join_queue_screen.dart'; +import 'package:doubtqueue/screens/mentor_screen.dart'; +import 'package:doubtqueue/screens/my_doubt_screen.dart'; +import 'package:doubtqueue/screens/session_list_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'fake_backend.dart'; + +const widths = [360.0, 390.0, 600.0, 820.0, 1024.0, 1440.0]; + +FakeBackend busyBackend() { + final backend = FakeBackend( + sessions: [ + FakeBackend.openSession(1, 'PHP Basics Doubt Session'), + FakeBackend.openSession(2, 'Flutter Layout Session', mentor: 'Arjun'), + ], + ); + backend.addDoubt(1, 'Asha'); + backend.addDoubt(1, 'Ben'); + backend.addDoubt(1, 'Chitra'); + return backend; +} + +Future pumpAtWidth( + WidgetTester tester, + double width, + Widget screen, { + double height = 900, +}) async { + tester.view.physicalSize = Size(width, height); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + await tester.pumpWidget(MaterialApp(home: screen)); + await tester.pumpAndSettle(); +} + +void main() { + // Layout problems such as overflow are reported as test exceptions. + // The test font is wider than real fonts, so these checks are strict. + for (final width in widths) { + group('at ${width.toInt()}px', () { + testWidgets('session list fits', (tester) async { + await pumpAtWidth( + tester, + width, + SessionListScreen(api: busyBackend().api), + ); + + await tester.tap(find.text('All')); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.text('Join queue'), findsWidgets); + }); + + testWidgets('join queue form fits', (tester) async { + final session = Session.fromJson({ + ...FakeBackend.openSession(1, 'PHP Basics Doubt Session'), + 'waiting_count': 3, + }); + await pumpAtWidth( + tester, + width, + JoinQueueScreen(api: busyBackend().api, session: session), + ); + + expect(tester.takeException(), isNull); + }); + + testWidgets('my doubt screen fits', (tester) async { + final backend = busyBackend(); + final doubt = Doubt.fromJson({...backend.doubts.last, 'position': 3}); + await pumpAtWidth( + tester, + width, + MyDoubtScreen(api: backend.api, doubt: doubt), + ); + + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('mentor dashboard fits', (tester) async { + await pumpAtWidth( + tester, + width, + MentorScreen(api: busyBackend().api), + height: 3000, + ); + + await tester.tap(find.byType(DropdownButton)); + await tester.pumpAndSettle(); + await tester.tap( + find.textContaining('PHP Basics Doubt Session (Priya)').last, + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Take next student')); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.text('Mark solved'), findsOneWidget); + }); + }); + } +} diff --git a/app/test/session_filter_test.dart b/app/test/session_filter_test.dart new file mode 100644 index 0000000..ab638bb --- /dev/null +++ b/app/test/session_filter_test.dart @@ -0,0 +1,94 @@ +import 'package:doubtqueue/models/session.dart'; +import 'package:doubtqueue/utils/session_filter.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Session session( + int id, + String title, { + String mentor = 'Priya', + String status = 'OPEN', + int waiting = 0, + DateTime? createdAt, +}) { + return Session( + id: id, + title: title, + mentorName: mentor, + status: status, + waitingCount: waiting, + createdAt: createdAt ?? DateTime.utc(2026, 1, id), + ); +} + +List ids(List sessions) => sessions.map((s) => s.id).toList(); + +void main() { + final sessions = [ + session(1, 'PHP Basics', mentor: 'Priya', waiting: 2), + session(2, 'Flutter Layouts', mentor: 'Arjun', waiting: 7), + session(3, 'SQL Practice', mentor: 'Priya', waiting: 4, status: 'CLOSED'), + session(4, 'Dart Async', mentor: 'Meera', waiting: 0), + ]; + + group('search', () { + test('matches the session title', () { + final result = filterSessions(sessions, query: 'flutter'); + expect(ids(result), [2]); + }); + + test('matches the mentor name', () { + final result = filterSessions( + sessions, + query: 'PRIYA', + status: SessionStatusFilter.all, + ); + expect(ids(result), containsAll([1, 3])); + expect(result, hasLength(2)); + }); + + test('an empty search returns every session in the filter', () { + expect(filterSessions(sessions, query: ' '), hasLength(3)); + }); + + test('returns nothing when there is no match', () { + expect(filterSessions(sessions, query: 'python'), isEmpty); + }); + }); + + group('status filter', () { + test('open shows only open sessions', () { + expect(ids(filterSessions(sessions)), [4, 2, 1]); + }); + + test('closed shows only closed sessions', () { + final result = filterSessions( + sessions, + status: SessionStatusFilter.closed, + ); + expect(ids(result), [3]); + }); + + test('all shows open sessions before closed ones', () { + final result = filterSessions(sessions, status: SessionStatusFilter.all); + expect(ids(result), [4, 2, 1, 3]); + }); + }); + + group('sorting', () { + test('newest puts the latest created session first', () { + final result = filterSessions(sessions, sort: SessionSort.newest); + expect(ids(result), [4, 2, 1]); + }); + + test('most waiting puts the longest queue first', () { + final result = filterSessions(sessions, sort: SessionSort.mostWaiting); + expect(ids(result), [2, 1, 4]); + }); + + test('most waiting falls back to newest when counts are equal', () { + final equal = [session(1, 'A', waiting: 3), session(2, 'B', waiting: 3)]; + final result = filterSessions(equal, sort: SessionSort.mostWaiting); + expect(ids(result), [2, 1]); + }); + }); +} diff --git a/app/test/session_list_screen_test.dart b/app/test/session_list_screen_test.dart index 45a7f30..21d0451 100644 --- a/app/test/session_list_screen_test.dart +++ b/app/test/session_list_screen_test.dart @@ -1,45 +1,171 @@ -import 'dart:convert'; - import 'package:doubtqueue/screens/session_list_screen.dart'; -import 'package:doubtqueue/services/api_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:http/http.dart' as http; -import 'package:http/testing.dart'; + +import 'fake_backend.dart'; + +FakeBackend backendWithSessions() { + final backend = FakeBackend( + sessions: [ + FakeBackend.openSession(1, 'PHP Basics Doubt Session'), + FakeBackend.openSession( + 2, + 'Flutter Layout Session', + mentor: 'Arjun', + createdAt: '2026-01-02 10:00:00', + ), + { + ...FakeBackend.openSession(3, 'SQL Practice', mentor: 'Meera'), + 'status': 'CLOSED', + 'closed_at': '2026-01-01 12:00:00', + }, + ], + ); + backend.addDoubt(1, 'Asha'); + backend.addDoubt(1, 'Ben'); + return backend; +} + +Future pumpList(WidgetTester tester, FakeBackend backend) async { + tester.view.physicalSize = const Size(800, 2000); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + MaterialApp(home: SessionListScreen(api: backend.api)), + ); + await tester.pumpAndSettle(); +} + +double topOf(WidgetTester tester, String text) { + return tester.getTopLeft(find.text(text)).dy; +} void main() { - testWidgets('shows a card for each open session', (tester) async { - final client = MockClient((request) async { - return http.Response( - jsonEncode([ - { - 'id': 1, - 'title': 'PHP Basics Doubt Session', - 'mentor_name': 'Priya', - 'status': 'OPEN', - 'waiting_count': 5, - }, - { - 'id': 2, - 'title': 'Laravel Doubt Session', - 'mentor_name': 'Kiran', - 'status': 'OPEN', - 'waiting_count': 0, - }, - ]), - 200, - ); - }); - final api = ApiService(client, baseUrl: 'http://test.local'); - - await tester.pumpWidget(MaterialApp(home: SessionListScreen(api: api))); - await tester.pumpAndSettle(); - - expect(find.byType(Card), findsNWidgets(2)); + testWidgets('shows a card for each open session by default', (tester) async { + await pumpList(tester, backendWithSessions()); + expect(find.text('PHP Basics Doubt Session'), findsOneWidget); expect(find.text('Mentor: Priya'), findsOneWidget); - expect(find.text('5 waiting'), findsOneWidget); - expect(find.text('Laravel Doubt Session'), findsOneWidget); - expect(find.text('Join Queue'), findsNWidgets(2)); + expect(find.text('2 students waiting'), findsOneWidget); + expect(find.text('Flutter Layout Session'), findsOneWidget); + expect(find.text('SQL Practice'), findsNothing); + expect(find.text('Join queue'), findsNWidgets(2)); + }); + + testWidgets('closed sessions have no join button', (tester) async { + await pumpList(tester, backendWithSessions()); + + await tester.tap(find.text('Closed')); + await tester.pumpAndSettle(); + + expect(find.text('SQL Practice'), findsOneWidget); + expect(find.text('This session has ended.'), findsOneWidget); + expect(find.text('Join queue'), findsNothing); + }); + + testWidgets('the All filter lists open sessions before closed ones', ( + tester, + ) async { + await pumpList(tester, backendWithSessions()); + + await tester.tap(find.text('All')); + await tester.pumpAndSettle(); + + expect(find.text('Join queue'), findsNWidgets(2)); + expect( + topOf(tester, 'Flutter Layout Session'), + lessThan(topOf(tester, 'SQL Practice')), + ); + }); + + testWidgets('search filters by session title and mentor name', ( + tester, + ) async { + await pumpList(tester, backendWithSessions()); + + await tester.enterText(find.byType(TextField), 'flutter'); + await tester.pumpAndSettle(); + expect(find.text('Flutter Layout Session'), findsOneWidget); + expect(find.text('PHP Basics Doubt Session'), findsNothing); + + await tester.enterText(find.byType(TextField), 'priya'); + await tester.pumpAndSettle(); + expect(find.text('PHP Basics Doubt Session'), findsOneWidget); + expect(find.text('Flutter Layout Session'), findsNothing); + }); + + testWidgets('search with no result shows an empty state', (tester) async { + await pumpList(tester, backendWithSessions()); + + await tester.enterText(find.byType(TextField), 'python'); + await tester.pumpAndSettle(); + + expect(find.text('No sessions match your search'), findsOneWidget); + + await tester.tap(find.text('Clear search')); + await tester.pumpAndSettle(); + + expect(find.text('PHP Basics Doubt Session'), findsOneWidget); + }); + + testWidgets('sorting switches between newest and most waiting', ( + tester, + ) async { + await pumpList(tester, backendWithSessions()); + + expect( + topOf(tester, 'Flutter Layout Session'), + lessThan(topOf(tester, 'PHP Basics Doubt Session')), + ); + + await tester.tap(find.text('Most waiting')); + await tester.pumpAndSettle(); + + expect( + topOf(tester, 'PHP Basics Doubt Session'), + lessThan(topOf(tester, 'Flutter Layout Session')), + ); + }); + + testWidgets('shows an empty state when there are no open sessions', ( + tester, + ) async { + await pumpList(tester, FakeBackend(sessions: [])); + + expect(find.text('No open sessions right now'), findsOneWidget); + expect( + find.text('Check again later or ask your mentor to start a session.'), + findsOneWidget, + ); + expect(find.text('Refresh'), findsOneWidget); + }); + + testWidgets('shows an error state and recovers with Try again', ( + tester, + ) async { + final backend = backendWithSessions()..failRequests = true; + await pumpList(tester, backend); + + expect(find.text('Could not load this page'), findsOneWidget); + expect(find.text('Try again'), findsOneWidget); + + backend.failRequests = false; + await tester.tap(find.text('Try again')); + await tester.pumpAndSettle(); + + expect(find.text('PHP Basics Doubt Session'), findsOneWidget); + }); + + testWidgets('shows a loading message while sessions load', (tester) async { + final backend = backendWithSessions(); + await tester.pumpWidget( + MaterialApp(home: SessionListScreen(api: backend.api)), + ); + + expect(find.text('Loading sessions...'), findsOneWidget); + + await tester.pumpAndSettle(); + expect(find.text('Loading sessions...'), findsNothing); }); } diff --git a/app/test/time_format_test.dart b/app/test/time_format_test.dart new file mode 100644 index 0000000..7cdd630 --- /dev/null +++ b/app/test/time_format_test.dart @@ -0,0 +1,43 @@ +import 'package:doubtqueue/utils/time_format.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('parseApiDate reads a UTC API date', () { + final date = parseApiDate('2026-01-05 10:30:00'); + + expect(date, DateTime.utc(2026, 1, 5, 10, 30)); + expect(date!.isUtc, isTrue); + }); + + test('parseApiDate returns null for missing or invalid values', () { + expect(parseApiDate(null), isNull); + expect(parseApiDate(''), isNull); + expect(parseApiDate('not a date'), isNull); + }); + + test('timeAgo describes minutes, hours and days', () { + final now = DateTime.utc(2026, 1, 5, 12); + + expect(timeAgo(now, now: now), 'just now'); + expect( + timeAgo(now.subtract(const Duration(minutes: 5)), now: now), + '5 min ago', + ); + expect( + timeAgo(now.subtract(const Duration(hours: 1)), now: now), + '1 hour ago', + ); + expect( + timeAgo(now.subtract(const Duration(hours: 3)), now: now), + '3 hours ago', + ); + expect( + timeAgo(now.subtract(const Duration(days: 2)), now: now), + '2 days ago', + ); + }); + + test('timeAgo returns an empty string when the time is unknown', () { + expect(timeAgo(null), ''); + }); +}