diff --git a/lib/models/forum_models.dart b/lib/models/forum_models.dart index 4338a41..4aa8ed2 100644 --- a/lib/models/forum_models.dart +++ b/lib/models/forum_models.dart @@ -104,6 +104,30 @@ class ForumPost { bool isLikedBy(String? userID) => userID != null && userID.isNotEmpty && likes.contains(userID); + ForumPost copyWithLikes(String userID, {required bool liked}) { + final newLikes = List.from(likes); + if (liked) { + if (!newLikes.contains(userID)) newLikes.add(userID); + } else { + newLikes.remove(userID); + } + return ForumPost( + id: id, + post: post, + school: school, + title: title, + content: content, + anonymous: anonymous, + pin: pin, + tags: tags, + images: images, + likes: newLikes, + user: user, + created: created, + updated: updated, + ); + } + factory ForumPost.fromJson(Map json) { final user = json['user']; return ForumPost( @@ -145,6 +169,23 @@ class ForumMessage { bool isLikedBy(String? userID) => userID != null && userID.isNotEmpty && likes.contains(userID); + ForumMessage copyWithLikes(String userID, {required bool liked}) { + final newLikes = List.from(likes); + if (liked) { + if (!newLikes.contains(userID)) newLikes.add(userID); + } else { + newLikes.remove(userID); + } + return ForumMessage( + id: id, + content: content, + anonymous: anonymous, + user: user, + created: created, + likes: newLikes, + ); + } + factory ForumMessage.fromJson(Map json) { final user = json['user']; return ForumMessage( diff --git a/lib/screens/attendance_screen.dart b/lib/screens/attendance_screen.dart index 2ba56e1..eee1732 100644 --- a/lib/screens/attendance_screen.dart +++ b/lib/screens/attendance_screen.dart @@ -4,7 +4,8 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../models/models.dart'; import '../services/api_service.dart'; -import 'unsupported_screen.dart'; +import '../widgets/async_content_builder.dart'; +import '../widgets/empty_tile.dart'; class AttendanceScreen extends StatefulWidget { const AttendanceScreen({super.key}); @@ -168,12 +169,14 @@ class _AttendanceScreenState extends State { } Widget _buildBody() { - if (_isLoading) return const Center(child: CircularProgressIndicator()); - if (_unsupported) { - return const UnsupportedScreen(title: '此功能不支援', message: '目前選擇的學校尚未支援此功能'); - } - if (_error != null) { - return UnsupportedScreen(title: '載入失敗', message: _error!, onRetry: _loadData); + if (_isLoading || _unsupported || _error != null) { + return AsyncContentBuilder( + isLoading: _isLoading, + isUnsupported: _unsupported, + error: _error, + onRetry: _loadData, + child: const SizedBox.shrink(), + ); } final stats = _filteredStatistics; @@ -205,7 +208,7 @@ class _AttendanceScreenState extends State { Text('各科缺曠統計', style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 8), if (_subjectAbsences.isEmpty) - const _EmptyTile(message: '無缺曠記錄') + const EmptyTile(message: '無缺曠記錄') else ..._subjectAbsences .where((e) => e.total > 0) @@ -217,7 +220,7 @@ class _AttendanceScreenState extends State { style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 8), if (grouped.isEmpty) - _EmptyTile(message: _searchText.isEmpty ? '無缺曠記錄' : '找不到符合的記錄') + EmptyTile(message: _searchText.isEmpty ? '無缺曠記錄' : '找不到符合的記錄') else ...grouped.map((g) => _AbsenceDayRow( date: g.date, @@ -527,21 +530,4 @@ class _Badge extends StatelessWidget { } } -class _EmptyTile extends StatelessWidget { - final String message; - const _EmptyTile({required this.message}); - @override - Widget build(BuildContext context) { - return Card( - child: Padding( - padding: const EdgeInsets.all(20), - child: Row(children: [ - const Icon(Icons.info_outline, color: Colors.grey), - const SizedBox(width: 12), - Text(message), - ]), - ), - ); - } -} diff --git a/lib/screens/curriculum_screen.dart b/lib/screens/curriculum_screen.dart index 0f06ebc..db5e43e 100644 --- a/lib/screens/curriculum_screen.dart +++ b/lib/screens/curriculum_screen.dart @@ -8,6 +8,7 @@ import '../services/api_service.dart'; import '../services/cache_service.dart'; import '../services/notification_token_service.dart'; import '../services/school_config_manager.dart'; +import '../widgets/timetable_cells.dart'; import 'unsupported_screen.dart'; class CurriculumScreen extends StatefulWidget { @@ -556,8 +557,8 @@ class _CurriculumScreenState extends State { children: [ // Header TableRow(children: [ - _headerCell('節次'), - ..._weekdays.map((d) => _headerCell('週$d')), + const TimetableHeaderCell(text: '節次'), + ..._weekdays.map((d) => TimetableHeaderCell(text: '週$d')), ]), // Rows ...periods.map((period) => TableRow(children: [ @@ -582,15 +583,6 @@ class _CurriculumScreenState extends State { ); } - Widget _headerCell(String text) { - return Container( - padding: const EdgeInsets.all(8), - color: Colors.grey[200], - alignment: Alignment.center, - child: Text(text, style: const TextStyle(fontSize: 12)), - ); - } - Widget _periodCell(String period) { final pt = _effectivePeriodTime(period); final isManual = _manualPeriodTimes.containsKey(period); diff --git a/lib/screens/exam_score_screen.dart b/lib/screens/exam_score_screen.dart index 1cdbe46..e96cc38 100644 --- a/lib/screens/exam_score_screen.dart +++ b/lib/screens/exam_score_screen.dart @@ -3,6 +3,9 @@ import 'package:provider/provider.dart'; import '../models/models.dart'; import '../services/api_service.dart'; +import '../widgets/async_content_builder.dart'; +import '../widgets/empty_tile.dart'; +import '../widgets/score_label.dart'; import 'unsupported_screen.dart'; class ExamScoreScreen extends StatefulWidget { @@ -66,20 +69,13 @@ class _ExamScoreScreenState extends State { } Widget _buildBody() { - if (_isLoading) { - return const Center(child: CircularProgressIndicator()); - } - if (_unsupported) { - return const UnsupportedScreen( - title: '此功能不支援', - message: '目前選擇的學校尚未支援此功能', - ); - } - if (_error != null) { - return UnsupportedScreen( - title: '載入失敗', - message: _error!, + if (_isLoading || _unsupported || _error != null) { + return AsyncContentBuilder( + isLoading: _isLoading, + isUnsupported: _unsupported, + error: _error, onRetry: _loadMenu, + child: const SizedBox.shrink(), ); } if (_menu.isEmpty) { @@ -195,7 +191,7 @@ class _ExamScoreDetailScreenState extends State { Text('科目成績', style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 8), if (_data.subjects.isEmpty) - const _EmptyTile(message: '無成績資料') + const EmptyTile(message: '無成績資料') else ..._data.subjects.map((s) => _ExamSubjectCard(score: s)), ], @@ -222,8 +218,8 @@ class _ExamSubjectCard extends StatelessWidget { const SizedBox(height: 6), Row( children: [ - Expanded(child: _ScoreLabel(label: '個人', value: score.personalScore)), - Expanded(child: _ScoreLabel(label: '班均', value: score.classAverage)), + Expanded(child: ScoreLabel(label: '個人', value: score.personalScore)), + Expanded(child: ScoreLabel(label: '班均', value: score.classAverage)), ], ), ], @@ -233,43 +229,4 @@ class _ExamSubjectCard extends StatelessWidget { } } -class _ScoreLabel extends StatelessWidget { - final String label; - final String value; - - const _ScoreLabel({required this.label, required this.value}); - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(label, style: Theme.of(context).textTheme.labelSmall), - const SizedBox(height: 2), - Text(value, style: Theme.of(context).textTheme.titleSmall), - ], - ); - } -} - -class _EmptyTile extends StatelessWidget { - final String message; - - const _EmptyTile({required this.message}); - - @override - Widget build(BuildContext context) { - return Card( - child: Padding( - padding: const EdgeInsets.all(20), - child: Row( - children: [ - const Icon(Icons.info_outline, color: Colors.grey), - const SizedBox(width: 12), - Text(message), - ], - ), - ), - ); - } -} diff --git a/lib/screens/following_screen.dart b/lib/screens/following_screen.dart index 0194707..1f9ade0 100644 --- a/lib/screens/following_screen.dart +++ b/lib/screens/following_screen.dart @@ -5,6 +5,7 @@ import '../models/models.dart'; import '../services/api_service.dart'; import '../services/cache_service.dart'; import '../services/vocpass_auth_service.dart'; +import '../widgets/timetable_cells.dart'; import 'unsupported_screen.dart'; // MARK: - 追蹤名單 @@ -257,13 +258,13 @@ class _SharedCurriculumScreenState extends State { final tableRows = []; tableRows.add(TableRow(children: [ - _headerCell('節次'), - ..._weekdays.map((day) => _headerCell('週$day')), + const TimetableHeaderCell(text: '節次'), + ..._weekdays.map((day) => TimetableHeaderCell(text: '週$day')), ])); for (final period in periods) { tableRows.add(TableRow(children: [ - _periodCell(period), + TimetablePeriodCell(text: period), ..._weekdays.map((day) => _subjectCell(_subjectAt(day, period))), ])); } @@ -275,24 +276,6 @@ class _SharedCurriculumScreenState extends State { ); } - Widget _headerCell(String text) { - return Container( - padding: const EdgeInsets.all(8), - color: Colors.grey[200], - alignment: Alignment.center, - child: Text(text, style: const TextStyle(fontSize: 12)), - ); - } - - Widget _periodCell(String text) { - return Container( - padding: const EdgeInsets.all(8), - color: Colors.grey[100], - alignment: Alignment.center, - child: Text(text, style: const TextStyle(fontSize: 12)), - ); - } - Widget _subjectCell(String subject) { return Container( padding: const EdgeInsets.all(6), diff --git a/lib/screens/forum/forum_post_detail_screen.dart b/lib/screens/forum/forum_post_detail_screen.dart index ce1e8db..7bbe939 100644 --- a/lib/screens/forum/forum_post_detail_screen.dart +++ b/lib/screens/forum/forum_post_detail_screen.dart @@ -96,13 +96,13 @@ class _ForumPostDetailScreenState extends State { final userID = _currentUserID; if (userID == null) return; final liked = _post.isLikedBy(userID); - setState(() => _post = _withPostLike(_post, userID, !liked)); + setState(() => _post = _post.copyWithLikes(userID, liked: !liked)); try { await ForumService.instance .setPostLike(postID: _post.likeTargetID, liked: !liked); } catch (e) { if (!mounted) return; - setState(() => _post = _withPostLike(_post, userID, liked)); + setState(() => _post = _post.copyWithLikes(userID, liked: liked)); _showError(e); } } @@ -114,13 +114,13 @@ class _ForumPostDetailScreenState extends State { final liked = message.isLikedBy(userID); final index = _messages.indexWhere((m) => m.id == message.id); if (index < 0) return; - setState(() => _messages[index] = _withMessageLike(message, userID, !liked)); + setState(() => _messages[index] = message.copyWithLikes(userID, liked: !liked)); try { await ForumService.instance .setMessageLike(messageID: message.id, liked: !liked); } catch (e) { if (!mounted) return; - setState(() => _messages[index] = _withMessageLike(message, userID, liked)); + setState(() => _messages[index] = message.copyWithLikes(userID, liked: liked)); _showError(e); } } @@ -527,48 +527,6 @@ class _ForumPostDetailScreenState extends State { ); } - // MARK: - 樂觀更新輔助 - - ForumPost _withPostLike(ForumPost post, String userID, bool liked) { - final likes = List.from(post.likes); - if (liked) { - if (!likes.contains(userID)) likes.add(userID); - } else { - likes.remove(userID); - } - return ForumPost( - id: post.id, - post: post.post, - school: post.school, - title: post.title, - content: post.content, - anonymous: post.anonymous, - pin: post.pin, - tags: post.tags, - images: post.images, - likes: likes, - user: post.user, - created: post.created, - updated: post.updated, - ); - } - - ForumMessage _withMessageLike(ForumMessage message, String userID, bool liked) { - final likes = List.from(message.likes); - if (liked) { - if (!likes.contains(userID)) likes.add(userID); - } else { - likes.remove(userID); - } - return ForumMessage( - id: message.id, - content: message.content, - anonymous: message.anonymous, - user: message.user, - created: message.created, - likes: likes, - ); - } } class _LikeButton extends StatelessWidget { diff --git a/lib/screens/forum/forum_screen.dart b/lib/screens/forum/forum_screen.dart index e8bb5a5..e3ba4a4 100644 --- a/lib/screens/forum/forum_screen.dart +++ b/lib/screens/forum/forum_screen.dart @@ -189,13 +189,13 @@ class _ForumScreenState extends State { final index = _posts.indexWhere((p) => p.id == post.id); if (index < 0) return; final liked = post.isLikedBy(userID); - setState(() => _posts[index] = _toggledLike(post, userID, !liked)); + setState(() => _posts[index] = post.copyWithLikes(userID, liked: !liked)); try { await ForumService.instance .setPostLike(postID: post.likeTargetID, liked: !liked); } catch (e) { if (!mounted) return; - setState(() => _posts[index] = _toggledLike(post, userID, liked)); + setState(() => _posts[index] = post.copyWithLikes(userID, liked: liked)); ScaffoldMessenger.of(context) .showSnackBar(SnackBar(content: Text(e.toString()))); } @@ -531,29 +531,6 @@ class _ForumScreenState extends State { return const SizedBox(height: 80); } - ForumPost _toggledLike(ForumPost post, String userID, bool liked) { - final likes = List.from(post.likes); - if (liked) { - if (!likes.contains(userID)) likes.add(userID); - } else { - likes.remove(userID); - } - return ForumPost( - id: post.id, - post: post.post, - school: post.school, - title: post.title, - content: post.content, - anonymous: post.anonymous, - pin: post.pin, - tags: post.tags, - images: post.images, - likes: likes, - user: post.user, - created: post.created, - updated: post.updated, - ); - } } class _SchoolIcon extends StatelessWidget { diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 1021375..e468b3f 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -3,7 +3,8 @@ import 'package:provider/provider.dart'; import '../models/models.dart'; import '../services/api_service.dart'; -import 'unsupported_screen.dart'; +import '../widgets/async_content_builder.dart'; +import '../widgets/empty_tile.dart'; class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); @@ -68,20 +69,13 @@ class _HomeScreenState extends State { } Widget _buildBody() { - if (_isLoading) { - return const Center(child: CircularProgressIndicator()); - } - if (_unsupported) { - return const UnsupportedScreen( - title: '此功能不支援', - message: '目前選擇的學校尚未支援此功能', - ); - } - if (_error != null) { - return UnsupportedScreen( - title: '載入失敗', - message: _error!, + if (_isLoading || _unsupported || _error != null) { + return AsyncContentBuilder( + isLoading: _isLoading, + isUnsupported: _unsupported, + error: _error, onRetry: _loadData, + child: const SizedBox.shrink(), ); } @@ -101,7 +95,7 @@ class _HomeScreenState extends State { Widget _buildSection(String title, List records, {required bool isMerit}) { if (records.isEmpty) { - return _EmptyCard(title: title, message: '無$title記錄'); + return EmptyTile(message: '無$title記錄'); } return Column( @@ -189,25 +183,4 @@ class _MeritCard extends StatelessWidget { } } -class _EmptyCard extends StatelessWidget { - final String title; - final String message; - - const _EmptyCard({required this.title, required this.message}); - @override - Widget build(BuildContext context) { - return Card( - child: Padding( - padding: const EdgeInsets.all(20), - child: Row( - children: [ - const Icon(Icons.info_outline, color: Colors.grey), - const SizedBox(width: 12), - Text(message), - ], - ), - ), - ); - } -} diff --git a/lib/screens/school_notice_screen.dart b/lib/screens/school_notice_screen.dart index a397281..221bf6c 100644 --- a/lib/screens/school_notice_screen.dart +++ b/lib/screens/school_notice_screen.dart @@ -4,6 +4,7 @@ import 'package:url_launcher/url_launcher.dart'; import '../models/models.dart'; import '../services/api_service.dart'; +import '../widgets/async_content_builder.dart'; import 'unsupported_screen.dart'; class SchoolNoticeScreen extends StatefulWidget { @@ -75,21 +76,13 @@ class _SchoolNoticeScreenState extends State { } Widget _buildBody() { - if (_isLoading) { - return const Center(child: CircularProgressIndicator()); - } - if (_unsupported) { - return const UnsupportedScreen( - title: '此功能不支援', - message: '目前選擇的學校尚未支援此功能', - showRetry: false, - ); - } - if (_error != null) { - return UnsupportedScreen( - title: '載入失敗', - message: _error!, + if (_isLoading || _unsupported || _error != null) { + return AsyncContentBuilder( + isLoading: _isLoading, + isUnsupported: _unsupported, + error: _error, onRetry: _loadData, + child: const SizedBox.shrink(), ); } if (_notices.isEmpty) { diff --git a/lib/screens/score_screen.dart b/lib/screens/score_screen.dart index 2c0e504..663bb21 100644 --- a/lib/screens/score_screen.dart +++ b/lib/screens/score_screen.dart @@ -3,8 +3,10 @@ import 'package:provider/provider.dart'; import '../models/models.dart'; import '../services/api_service.dart'; +import '../widgets/async_content_builder.dart'; +import '../widgets/empty_tile.dart'; +import '../widgets/score_label.dart'; import 'exam_score_screen.dart'; -import 'unsupported_screen.dart'; class ScoreScreen extends StatefulWidget { const ScoreScreen({super.key}); @@ -80,20 +82,13 @@ class _ScoreScreenState extends State { } Widget _buildBody() { - if (_isLoading) { - return const Center(child: CircularProgressIndicator()); - } - if (_unsupported) { - return const UnsupportedScreen( - title: '此功能不支援', - message: '目前選擇的學校尚未支援此功能', - ); - } - if (_error != null) { - return UnsupportedScreen( - title: '載入失敗', - message: _error!, + if (_isLoading || _unsupported || _error != null) { + return AsyncContentBuilder( + isLoading: _isLoading, + isUnsupported: _unsupported, + error: _error, onRetry: _loadData, + child: const SizedBox.shrink(), ); } @@ -115,7 +110,7 @@ class _ScoreScreenState extends State { Text('科目成績', style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 8), if (_gradeData.subjects.isEmpty) - const _EmptyTile(message: '無成績資料') + const EmptyTile(message: '無成績資料') else ..._gradeData.subjects.map((subject) => _SubjectGradeCard(subject: subject)), const SizedBox(height: 16), @@ -279,9 +274,9 @@ class _TotalScoreCard extends StatelessWidget { const SizedBox(height: 6), Row( children: [ - Expanded(child: _ScoreLabel(label: '上學期', value: score.firstSemester)), - Expanded(child: _ScoreLabel(label: '下學期', value: score.secondSemester)), - Expanded(child: _ScoreLabel(label: '學年', value: score.year)), + Expanded(child: ScoreLabel(label: '上學期', value: score.firstSemester)), + Expanded(child: ScoreLabel(label: '下學期', value: score.secondSemester)), + Expanded(child: ScoreLabel(label: '學年', value: score.year)), ], ), ], @@ -291,24 +286,7 @@ class _TotalScoreCard extends StatelessWidget { } } -class _ScoreLabel extends StatelessWidget { - final String label; - final String value; - const _ScoreLabel({required this.label, required this.value}); - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(label, style: Theme.of(context).textTheme.labelSmall), - const SizedBox(height: 2), - Text(value, style: Theme.of(context).textTheme.titleSmall), - ], - ); - } -} class _DailyPerformanceCard extends StatelessWidget { final String title; @@ -370,24 +348,4 @@ class _PerfRow extends StatelessWidget { } } -class _EmptyTile extends StatelessWidget { - final String message; - - const _EmptyTile({required this.message}); - @override - Widget build(BuildContext context) { - return Card( - child: Padding( - padding: const EdgeInsets.all(20), - child: Row( - children: [ - const Icon(Icons.info_outline, color: Colors.grey), - const SizedBox(width: 12), - Text(message), - ], - ), - ), - ); - } -} diff --git a/lib/widgets/async_content_builder.dart b/lib/widgets/async_content_builder.dart new file mode 100644 index 0000000..4f4f9b0 --- /dev/null +++ b/lib/widgets/async_content_builder.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; + +import '../screens/unsupported_screen.dart'; + +class AsyncContentBuilder extends StatelessWidget { + final bool isLoading; + final bool isUnsupported; + final String? error; + final VoidCallback? onRetry; + final Widget child; + + const AsyncContentBuilder({ + super.key, + required this.isLoading, + this.isUnsupported = false, + this.error, + this.onRetry, + required this.child, + }); + + @override + Widget build(BuildContext context) { + if (isLoading) { + return const Center(child: CircularProgressIndicator()); + } + if (isUnsupported) { + return const UnsupportedScreen( + title: '此功能不支援', + message: '目前選擇的學校尚未支援此功能', + ); + } + if (error != null) { + return UnsupportedScreen( + title: '載入失敗', + message: error!, + onRetry: onRetry, + ); + } + return child; + } +} diff --git a/lib/widgets/empty_tile.dart b/lib/widgets/empty_tile.dart new file mode 100644 index 0000000..3f3bb5e --- /dev/null +++ b/lib/widgets/empty_tile.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; + +class EmptyTile extends StatelessWidget { + final String message; + + const EmptyTile({super.key, required this.message}); + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + const Icon(Icons.info_outline, color: Colors.grey), + const SizedBox(width: 12), + Text(message), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/score_label.dart b/lib/widgets/score_label.dart new file mode 100644 index 0000000..1ad535b --- /dev/null +++ b/lib/widgets/score_label.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; + +class ScoreLabel extends StatelessWidget { + final String label; + final String value; + + const ScoreLabel({super.key, required this.label, required this.value}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelSmall), + const SizedBox(height: 2), + Text(value, style: Theme.of(context).textTheme.titleSmall), + ], + ); + } +} diff --git a/lib/widgets/timetable_cells.dart b/lib/widgets/timetable_cells.dart new file mode 100644 index 0000000..7bb3038 --- /dev/null +++ b/lib/widgets/timetable_cells.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; + +class TimetableHeaderCell extends StatelessWidget { + final String text; + + const TimetableHeaderCell({super.key, required this.text}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(8), + color: Colors.grey[200], + alignment: Alignment.center, + child: Text(text, style: const TextStyle(fontSize: 12)), + ); + } +} + +class TimetablePeriodCell extends StatelessWidget { + final String text; + + const TimetablePeriodCell({super.key, required this.text}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(8), + color: Colors.grey[100], + alignment: Alignment.center, + child: Text(text, style: const TextStyle(fontSize: 12)), + ); + } +}