Skip to content

AP Common UI v2#145

Merged
abc873693 merged 34 commits intodevelopfrom
feature/ap-common-ui-v2
Mar 18, 2026
Merged

AP Common UI v2#145
abc873693 merged 34 commits intodevelopfrom
feature/ap-common-ui-v2

Conversation

@abc873693
Copy link
Owner

@abc873693 abc873693 commented Mar 4, 2026

PR Type

Enhancement, Bug fix, Documentation, Other


Description

  • 引入動態主題切換與顏色選擇器。

  • 新增 SemesterPicker 提升學期選擇體驗。

  • 優化課表、成績、首頁等 Scaffold UI。

  • 強化錯誤、載入狀態與提示訊息顯示。

  • 新增成績分析功能,提供詳細統計。


Diagram Walkthrough

flowchart LR
    A[AP Common UI v2] --> B{核心功能增強};
    B --> C[動態主題與顏色選擇器];
    B --> D[SemesterPicker 學期選擇器];
    B --> E[Scaffold UI 全面優化];
    E --> E1[課表 Scaffold];
    E --> E2[成績 Scaffold];
    E --> E3[首頁 Scaffold];
    E2 --> E2a[新增成績分析];
Loading

@abc873693 abc873693 linked an issue Mar 4, 2026 that may be closed by this pull request
@github-actions
Copy link

github-actions bot commented Mar 4, 2026

PR Reviewer Guide 🔍

(Review updated until commit 8c5319b)

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 4 🔵🔵🔵🔵⚪
🧪 No relevant tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Const 建構子

CourseScaffold 的新 UI 實作中,許多 StatelessWidget 或具有固定屬性的 ContainerSizedBoxTextIcon 可以使用 const 建構子。這有助於 Flutter 在重建時優化性能,避免不必要的重繪。例如,_buildHintBanner()_buildErrorState() 內部的一些固定元件,以及 _buildTimeSlotHeader()_buildEmptyCell() 等方法返回的元件。

                  _buildHintBanner(),
                Expanded(
                  child: RefreshIndicator(
                    onRefresh: () async {
                      await widget.onRefresh!();
                      AnalyticsUtil.instance.logEvent('course_refresh');
                      return;
                    },
                    child: _body(),
                  ),
                ),
              ],
            ),
          ),
          if (widget.state == CourseState.finish && isLandscape) ...<Widget>[
            const SizedBox(width: 16.0),
            Expanded(
              flex: 2,
              child: Material(
                elevation: 12.0,
                child: ColoredBox(
                  color:
                      Theme.of(context).colorScheme.surfaceContainerHighest,
                  child: CourseList(
                    courses: widget.courseData.courses,
                    timeCodes: widget.courseData.timeCodes,
                    invisibleCourseCodes: invisibleCourseCodes,
                    onVisibilityChanged: (
                      Course course,
                      bool visibility,
                    ) =>
                        saveInvisibleCourseCodes(
                      course: course,
                      visibility: visibility,
                    ),
                  ),
                ),
              ),
            ),
          ],
        ],
      ),
      floatingActionButtonLocation: FloatingActionButtonLocation.endContained,
      floatingActionButton: !isLandscape
          ? FloatingActionButton(
              onPressed: () {
                setState(
                  () => _contentStyle = (_contentStyle == _ContentStyle.table)
                      ? _ContentStyle.list
                      : _ContentStyle.table,
                );
              },
              child: Icon(
                _contentStyle == _ContentStyle.table
                    ? Icons.list_rounded
                    : Icons.grid_view_rounded,
              ),
            )
          : null,
    ),
  );
}

String get hintContent {
  switch (widget.state) {
    case CourseState.error:
      return app.clickToRetry;
    case CourseState.empty:
      return app.courseEmpty;
    case CourseState.offlineEmpty:
      return app.noOfflineData;
    case CourseState.custom:
      return widget.customStateHint ?? app.unknownError;
    default:
      return '';
  }
}

Widget _buildErrorState(
  ColorScheme colorScheme,
  String message,
  IconData icon,
) {
  return InkWell(
    onTap: () {
      if (widget.state == CourseState.empty) {
        _pickSemester();
      } else {
        widget.onRefresh?.call();
      }
    },
    child: Center(
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: <Widget>[
          Container(
            width: 80,
            height: 80,
            decoration: BoxDecoration(
              color: colorScheme.primaryContainer.withAlpha(77),
              borderRadius: BorderRadius.circular(20),
            ),
            child: Icon(icon, size: 40, color: colorScheme.primary),
          ),
          const SizedBox(height: 16),
          Text(
            message,
            style: TextStyle(
              fontSize: 16,
              color: colorScheme.onSurfaceVariant,
            ),
            textAlign: TextAlign.center,
          ),
          const SizedBox(height: 8),
          Text(
            app.clickToRetry,
            style: TextStyle(fontSize: 14, color: colorScheme.primary),
          ),
        ],
      ),
    ),
  );
}

Widget _buildHintBanner() {
  return HintBanner(text: widget.customHint!);
}

Widget _body() {
  final ColorScheme colorScheme = Theme.of(context).colorScheme;
  switch (widget.state) {
    case CourseState.loading:
      return Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: <Widget>[
            SizedBox(
              width: 48,
              height: 48,
              child: CircularProgressIndicator(
                strokeWidth: 3,
                color: colorScheme.primary,
              ),
            ),
          ],
        ),
      );
    case CourseState.error:
      return _buildErrorState(
        colorScheme,
        app.clickToRetry,
        Icons.error_outline_rounded,
      );
    case CourseState.empty:
      return _buildErrorState(
        colorScheme,
        app.courseEmpty,
        Icons.event_busy_rounded,
      );
    case CourseState.offlineEmpty:
      return _buildErrorState(
        colorScheme,
        app.noOfflineData,
        Icons.cloud_off_rounded,
      );
    case CourseState.custom:
      return _buildErrorState(
        colorScheme,
        widget.customStateHint ?? app.unknownError,
        Icons.warning_amber_rounded,
      );
    default:
      if (isLandscape || _contentStyle == _ContentStyle.table) {
        final int weekdayCount = widget.courseData.hasHoliday ? 7 : 5;
        return SingleChildScrollView(
          physics: const AlwaysScrollableScrollPhysics(),
          padding: const EdgeInsets.only(
            top: 8.0,
            bottom: 80.0,
          ),
          child: RepaintBoundary(
            key: _repaintBoundaryGlobalKey,
            child: ColoredBox(
              color: Theme.of(context).scaffoldBackgroundColor,
              child: Column(
                children: <Widget>[
                  _buildWeekdayHeader(colorScheme, weekdayCount),
                  _buildCourseGrid(
                      colorScheme, weekdayCount, widget.courseData.timeCodes),
                ],
              ),
            ),
          ),
        );
      } else {
        return CourseList(
          courses: widget.courseData.courses,
          timeCodes: widget.courseData.timeCodes,
          invisibleCourseCodes: invisibleCourseCodes,
          onVisibilityChanged: (
            Course course,
            bool visibility,
          ) =>
              saveInvisibleCourseCodes(
            course: course,
            visibility: visibility,
          ),
        );
      }
  }
}

Future<void> _captureCourseTable() async {
  final RenderRepaintBoundary? boundary =
      _repaintBoundaryGlobalKey.currentContext!.findRenderObject()
          as RenderRepaintBoundary?;
  if (boundary == null) {
    UiUtil.instance.showToast(context, app.unknownError);
    return;
  }
  final ui.Image image = await boundary.toImage(pixelRatio: 3.0);
  final ByteData? byteData =
      await image.toByteData(format: ui.ImageByteFormat.png);
  final DateTime now = DateTime.now();
  final String formattedDate = DateFormat('yyyyMMdd_hhmmss').format(now);
  if (byteData != null) {
    if (!mounted) return;
    await MediaUtil.instance.saveImage(
      context,
      byteData: byteData,
      fileName: 'course_table_$formattedDate',
      successMessage: ApLocalizations.of(context).exportCourseTableSuccess,
      onSuccess: (GeneralResponse r) => Toast.show(
        r.message,
        context,
      ),
      onError: (GeneralResponse e) => Toast.show(
        e.message,
        context,
      ),
    );
    AnalyticsUtil.instance.logEvent('export_course_table_image_success');
  } else {
    if (!mounted) return;
    UiUtil.instance.showToast(context, app.unknownError);
  }
}

Widget _buildWeekdayHeader(ColorScheme colorScheme, int weekdayCount) {
  return Container(
    decoration: BoxDecoration(
      color: colorScheme.primaryContainer.withAlpha(77),
      border: Border(
        bottom: BorderSide(
          color: colorScheme.outlineVariant.withAlpha(128),
        ),
      ),
    ),
    child: Row(
      children: <Widget>[
        _buildTimeSlotHeader(colorScheme),
        for (int i = 0; i < weekdayCount; i++)
          Expanded(
            child: Container(
              padding: const EdgeInsets.symmetric(vertical: 12),
              child: Text(
                ApLocalizations.of(context).weekdaysCourse[i],
                textAlign: TextAlign.center,
                style: TextStyle(
                  fontSize: 14,
                  fontWeight: FontWeight.w600,
                  color: colorScheme.primary,
                ),
              ),
            ),
          ),
      ],
    ),
  );
}

Widget _buildTimeSlotHeader(ColorScheme colorScheme) {
  return Container(
    width: 48,
    padding: const EdgeInsets.symmetric(vertical: 12),
    child: Text(
      '',
      textAlign: TextAlign.center,
      style: TextStyle(
        fontSize: 12,
        fontWeight: FontWeight.w600,
        color: colorScheme.primary,
      ),
    ),
  );
}

Widget _buildCourseGrid(
  ColorScheme colorScheme,
  int weekdayCount,
  List<TimeCode> timeCodes,
) {
  final int minIndex = widget.courseData.minTimeCodeIndex;
  final int maxIndex = widget.courseData.maxTimeCodeIndex;

  return Row(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: <Widget>[
      _buildTimeColumn(colorScheme, timeCodes, minIndex, maxIndex),
      for (int weekday = 1; weekday <= weekdayCount; weekday++)
        Expanded(
          child: _buildWeekdayColumn(
            colorScheme,
            weekday,
            weekdayCount,
            minIndex,
            maxIndex,
          ),
        ),
    ],
  );
}

Widget _buildTimeColumn(
  ColorScheme colorScheme,
  List<TimeCode> timeCodes,
  int minIndex,
  int maxIndex,
) {
  return Column(
    children: <Widget>[
      for (int i = minIndex; i <= maxIndex; i++)
        _buildTimeSlot(
          colorScheme,
          i < timeCodes.length ? timeCodes[i] : null,
          i,
          i == maxIndex,
        ),
    ],
  );
}

Widget _buildWeekdayColumn(
  ColorScheme colorScheme,
  int weekday,
  int weekdayCount,
  int minIndex,
  int maxIndex,
) {
  final List<Widget> children = <Widget>[];
  for (int i = minIndex; i <= maxIndex; i++) {
    final Course? course = _getCourseAt(weekday, i);
    final bool isInvisible =
        course != null && invisibleCourseCodes.contains(course.code);

    if (course == null || isInvisible) {
      children.add(
        _buildEmptyCell(
          colorScheme,
          weekday < weekdayCount,
          i == maxIndex,
        ),
      );
    } else {
      int span = 1;
      if (mergeCourse ?? true) {
        while (i + span <= maxIndex &&
            _getCourseAt(weekday, i + span) == course) {
          span++;
        }
      }
      children.add(
        _buildCourseCell(
          colorScheme,
          weekday,
          i,
          course,
          span,
          weekday < weekdayCount,
          i + span - 1 == maxIndex,
        ),
      );
      i += span - 1;
    }
  }
  return Column(
    crossAxisAlignment: CrossAxisAlignment.stretch,
    children: children,
  );
}

Widget _buildTimeSlot(
  ColorScheme colorScheme,
  TimeCode? timeCode,
  int timeIndex,
  bool isLast,
) {
  return Container(
    width: 48,
    height: _courseHeight,
    padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
    decoration: BoxDecoration(
      color: colorScheme.surfaceContainerHighest.withAlpha(77),
      border: Border(
        right: BorderSide(
          color: colorScheme.outlineVariant.withAlpha(128),
        ),
        bottom: isLast
            ? BorderSide.none
            : BorderSide(
                color: colorScheme.outlineVariant.withAlpha(77),
                width: 0.5,
              ),
      ),
    ),
    child: Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: <Widget>[
        Text(
          timeCode?.title ?? '${timeIndex + 1}',
          style: TextStyle(
            fontSize: 13,
            fontWeight: FontWeight.w600,
            color: colorScheme.onSurfaceVariant,
          ),
        ),
        if (timeCode != null && (showSectionTime ?? true)) ...<Widget>[
          const SizedBox(height: 2),
          Text(
            timeCode.startTime,
            style: TextStyle(
              fontSize: 9,
              color: colorScheme.onSurfaceVariant.withAlpha(179),
            ),
          ),
        ],
      ],
    ),
  );
}

Widget _buildEmptyCell(
  ColorScheme colorScheme,
  bool showRightBorder,
  bool isLast,
) {
  return Container(
    height: _courseHeight,
    decoration: BoxDecoration(
      border: Border(
        right: showRightBorder
            ? BorderSide(
                color: colorScheme.outlineVariant.withAlpha(51),
                width: 0.5,
              )
            : BorderSide.none,
        bottom: isLast
            ? BorderSide.none
            : BorderSide(
                color: colorScheme.outlineVariant.withAlpha(77),
                width: 0.5,
              ),
      ),
    ),
  );
}

Widget _buildCourseCell(
  ColorScheme colorScheme,
  int weekday,
  int timeIndex,
  Course course,
  int span,
  bool showRightBorder,
  bool isLast,
) {
  return Container(
    height: _courseHeight * span,
    decoration: BoxDecoration(
      border: Border(
        right: showRightBorder
            ? BorderSide(
                color: colorScheme.outlineVariant.withAlpha(51),
                width: 0.5,
              )
            : BorderSide.none,
        bottom: isLast
            ? BorderSide.none
            : BorderSide(
                color: colorScheme.outlineVariant.withAlpha(77),
                width: 0.5,
              ),
      ),
    ),
    child: _buildCourseCard(colorScheme, weekday, timeIndex, course, span),
  );
}

void _buildCourseLookup() {
  _courseLookup = <int, Map<int, Course>>{};
  for (final Course course in widget.courseData.courses) {
    for (final SectionTime time in course.times) {
      _courseLookup
          .putIfAbsent(
            time.weekday,
            () => <int, Course>{},
          )[time.index] = course;
    }
  }
}

Course? _getCourseAt(int weekday, int timeIndex) {
  return _courseLookup[weekday]?[timeIndex];
}

Widget _buildCourseCard(
  ColorScheme colorScheme,
  int weekday,
  int timeIndex,
  Course course,
  int span,
) {
  final Color courseColor = _getCourseColor(course.code);
  final String locationInfo =
      (showClassroomLocation ?? true) && course.location != null
          ? course.location.toString()
          : '';
  final String instructorInfo =
      (showInstructors ?? true) ? course.getInstructors() : '';

  final Color onCourseColor =
      ThemeData.estimateBrightnessForColor(courseColor) == Brightness.dark
          ? Colors.white
          : Colors.black;

  final String displayInfo = <String>[
    if (instructorInfo.isNotEmpty) instructorInfo,
    if (locationInfo.isNotEmpty) locationInfo,
  ].join('\n');

  return GestureDetector(
    onTap: () {
      final TimeCode timeCode = timeIndex < widget.courseData.timeCodes.length
          ? widget.courseData.timeCodes[timeIndex]
          : TimeCode(title: '?', startTime: '?', endTime: '?');
      _onPressed(weekday, timeCode, course);
    },
    child: Container(
      width: double.infinity,
      height: double.infinity,
      margin: const EdgeInsets.all(2),
      padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 6),
      decoration: BoxDecoration(
        color: courseColor.withAlpha(230),
        borderRadius: BorderRadius.circular(8),
        boxShadow: <BoxShadow>[
          BoxShadow(
            color: courseColor.withAlpha(77),
            blurRadius: 4,
            offset: const Offset(0, 2),
          ),
        ],
      ),
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: <Widget>[
          Text(
            course.title,
            textAlign: TextAlign.center,
            maxLines: span > 1 ? 4 : 2,
            overflow: TextOverflow.ellipsis,
            style: TextStyle(
              fontSize: span > 1 ? 14 : 9.5,
              fontWeight: FontWeight.w600,
              color: onCourseColor,
              height: 1.1,
            ),
          ),
          if (displayInfo.isNotEmpty) ...<Widget>[
            SizedBox(height: span > 1 ? 4 : 2),
            Text(
              displayInfo,
              textAlign: TextAlign.center,
              maxLines: span > 1 ? 4 : 2,
              overflow: TextOverflow.ellipsis,
              style: TextStyle(
                fontSize: span > 1 ? 12 : 8.5,
                color: onCourseColor.withAlpha(217),
                height: 1.0,
              ),
            ),
          ],
        ],
      ),
    ),
  );
Const 建構子與新 Widget

ScoreScaffold 的新 UI 實作引入了 _ScoreListTab_ScoreAnalysisTab 等新的 StatelessWidget。這些新 Widget 及其內部許多固定元件(如 SizedBoxTextIconContainer 等)應盡可能使用 const 建構子,以提升應用程式的渲染效率。同時,ScoreAnalysis 類別的引入是良好的設計,將分數分析邏輯與 UI 分離。

      return _ScoreAnalysisTab(
        scoreData: widget.scoreData!,
        onRefresh: widget.onRefresh,
      );
    } else {
      return _ScoreListTab(
        scoreData: widget.scoreData!,
        onRefresh: widget.onRefresh,
        middleTitle: widget.middleTitle,
        finalTitle: widget.finalTitle,
        onScoreSelect: widget.onScoreSelect,
        middleScoreBuilder: widget.middleScoreBuilder,
        finalScoreBuilder: widget.finalScoreBuilder,
        details: widget.details,
      );
    }
  }
}

class _ScoreListTab extends StatelessWidget {
  const _ScoreListTab({
    required this.scoreData,
    this.onRefresh,
    this.middleTitle,
    this.finalTitle,
    this.onScoreSelect,
    this.middleScoreBuilder,
    this.finalScoreBuilder,
    this.details,
  });

  final ScoreData scoreData;
  final VoidCallback? onRefresh;
  final String? middleTitle;
  final String? finalTitle;
  final Function(int index)? onScoreSelect;
  final Widget Function(int index)? middleScoreBuilder;
  final Widget Function(int index)? finalScoreBuilder;
  final List<String>? details;

  @override
  Widget build(BuildContext context) {
    final ColorScheme colorScheme = Theme.of(context).colorScheme;

    return RefreshIndicator(
      onRefresh: () async => onRefresh?.call(),
      child: ListView.builder(
        physics: const AlwaysScrollableScrollPhysics(),
        padding: const EdgeInsets.all(16),
        itemCount: scoreData.scores.length +
            ((details != null && details!.isNotEmpty) ? 1 : 0),
        itemBuilder: (BuildContext context, int index) {
          if (index < scoreData.scores.length) {
            return _buildScoreItem(colorScheme, scoreData.scores[index], index);
          } else {
            return _buildDetailsCard(colorScheme);
          }
        },
      ),
    );
  }

  Widget _buildDetailsCard(ColorScheme colorScheme) {
    if (details == null || details!.isEmpty) return const SizedBox.shrink();
    return Container(
      margin: const EdgeInsets.only(bottom: 8, top: 8),
      decoration: BoxDecoration(
        color: colorScheme.surfaceContainerLowest,
        borderRadius: BorderRadius.circular(16),
        border: Border.all(color: colorScheme.outlineVariant.withAlpha(77)),
      ),
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          children: <Widget>[
            for (final String text in details!)
              Padding(
                padding: const EdgeInsets.symmetric(vertical: 4.0),
                child: SelectableText(
                  text,
                  textAlign: TextAlign.center,
                  style: TextStyle(
                    fontSize: 14,
                    color: colorScheme.onSurfaceVariant,
                  ),
                ),
              ),
          ],
        ),
      ),
    );
  }

  Widget _buildScoreItem(ColorScheme colorScheme, Score score, int index) {
    final String scoreStr = score.semesterScore ?? '';
    final double? scoreValue = double.tryParse(scoreStr);
    final bool isPassed = scoreValue != null && scoreValue >= 60;
    final Color scoreColor = scoreValue == null
        ? colorScheme.onSurfaceVariant
        : isPassed
            ? _getScoreColor(scoreValue)
            : colorScheme.error;

    return GestureDetector(
      onTap: onScoreSelect == null
          ? null
          : () {
              onScoreSelect!(index);
              AnalyticsUtil.instance.logEvent('score_title_click');
            },
      child: Container(
        margin: const EdgeInsets.only(bottom: 8),
        decoration: BoxDecoration(
          color: colorScheme.surfaceContainerLowest,
          borderRadius: BorderRadius.circular(16),
          border: Border.all(color: colorScheme.outlineVariant.withAlpha(77)),
        ),
        child: Padding(
          padding: const EdgeInsets.all(16),
          child: Row(
            children: <Widget>[
              Container(
                width: 4,
                height: 50,
                decoration: BoxDecoration(
                  color: scoreColor,
                  borderRadius: BorderRadius.circular(2),
                ),
              ),
              const SizedBox(width: 16),
              Expanded(
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: <Widget>[
                    Text(
                      score.title,
                      style: TextStyle(
                        fontSize: 15,
                        fontWeight: FontWeight.w600,
                        color: colorScheme.onSurface,
                      ),
                    ),
                    const SizedBox(height: 4),
                    Row(
                      children: <Widget>[
                        _buildTag(
                          colorScheme,
                          score.required ?? '',
                          colorScheme.tertiary,
                        ),
                        const SizedBox(width: 8),
                        Text(
                          '${score.units} 學分',
                          style: TextStyle(
                            fontSize: 12,
                            color: colorScheme.onSurfaceVariant,
                          ),
                        ),
                      ],
                    ),
                  ],
                ),
              ),
              Column(
                crossAxisAlignment: CrossAxisAlignment.end,
                children: <Widget>[
                  if (finalScoreBuilder == null)
                    Text(
                      score.semesterScore ?? '-',
                      style: TextStyle(
                        fontSize: 24,
                        fontWeight: FontWeight.bold,
                        color: scoreColor,
                      ),
                    ),
                  if (finalScoreBuilder != null) finalScoreBuilder!(index),
                  const SizedBox(height: 4),
                  if (middleScoreBuilder == null &&
                      score.middleScore != null &&
                      score.middleScore!.isNotEmpty)
                    Text(
                      '期中: ${score.middleScore}',
                      style: TextStyle(
                        fontSize: 11,
                        color: colorScheme.onSurfaceVariant,
                      ),
                    ),
                  if (middleScoreBuilder != null) middleScoreBuilder!(index),
                ],
              ),
            ],
          ),
        ),
      ),
    );
  }

  Color _getScoreColor(double score) {
    if (score >= 90) return const Color(0xFF4CAF50);
    if (score >= 80) return const Color(0xFF8BC34A);
    if (score >= 70) return const Color(0xFF2196F3);
    if (score >= 60) return const Color(0xFFFF9800);
    return const Color(0xFFF44336);
  }

  Widget _buildTag(ColorScheme colorScheme, String text, Color color) {
    if (text.isEmpty) return const SizedBox.shrink();

    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
      decoration: BoxDecoration(
        color: color.withAlpha(26),
        borderRadius: BorderRadius.circular(4),
      ),
      child: Text(
        text.replaceAll('【', '').replaceAll('】', ''),
        style: TextStyle(
          fontSize: 11,
          fontWeight: FontWeight.w600,
          color: color,
        ),
      ),
    );
  }
}

class _ScoreAnalysisTab extends StatelessWidget {
  const _ScoreAnalysisTab({required this.scoreData, this.onRefresh});

  final ScoreData scoreData;
  final VoidCallback? onRefresh;

  @override
  Widget build(BuildContext context) {
    final ColorScheme colorScheme = Theme.of(context).colorScheme;
    final ApLocalizations ap = ApLocalizations.of(context);
    final ScoreAnalysis analysis = ScoreAnalysis(scoreData);

    return RefreshIndicator(
      onRefresh: () async => onRefresh?.call(),
      child: SingleChildScrollView(
        physics: const AlwaysScrollableScrollPhysics(),
        padding: const EdgeInsets.all(16),
        child: Column(
          children: <Widget>[
            _buildMainSummaryCard(colorScheme, ap, analysis),
            const SizedBox(height: 16),
            ScorePRCard(analysis: analysis),
            const SizedBox(height: 16),
            ScoreStatisticsCard(analysis: analysis),
            const SizedBox(height: 16),
            ScoreDistributionCard(analysis: analysis),
            const SizedBox(height: 16),
            ScoreCreditSummaryCard(analysis: analysis),
            const SizedBox(height: 32),
          ],
        ),
      ),
    );
  }

  Widget _buildMainSummaryCard(
    ColorScheme colorScheme,
    ApLocalizations ap,
    ScoreAnalysis analysis,
  ) {
    final Detail detail = scoreData.detail;

    return Container(
      decoration: BoxDecoration(
        gradient: LinearGradient(
          begin: Alignment.topLeft,
          end: Alignment.bottomRight,
          colors: <Color>[
            colorScheme.primaryContainer,
            colorScheme.secondaryContainer,
          ],
        ),
        borderRadius: BorderRadius.circular(20),
        boxShadow: <BoxShadow>[
          BoxShadow(
            color: colorScheme.primary.withAlpha(38),
            blurRadius: 16,
            offset: const Offset(0, 8),
          ),
        ],
      ),
      child: Column(
        children: <Widget>[
          Padding(
            padding: const EdgeInsets.all(20),
            child: Row(
              children: <Widget>[
                Expanded(
                  child: _buildMainItem(
                    colorScheme,
                    Icons.star_rounded,
                    ap.average,
                    detail.average?.toStringAsFixed(2) ?? '-',
                  ),
                ),
                Container(
                  width: 1,
                  height: 60,
                  color: colorScheme.onPrimaryContainer.withAlpha(51),
                ),
                Expanded(
                  child: _buildMainItem(
                    colorScheme,
                    Icons.school_rounded,
                    ap.conductScore,
                    detail.conduct?.toStringAsFixed(0) ?? '-',
                  ),
                ),
              ],
            ),
          ),
          Container(
            padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
            decoration: BoxDecoration(
              color: colorScheme.surface.withAlpha(179),
              borderRadius: const BorderRadius.vertical(
                bottom: Radius.circular(20),
              ),
            ),
            child: Row(
              children: <Widget>[
                Expanded(
                  child: _buildRankItem(
                    colorScheme,
                    ap.classRank,
                    detail.classRank ?? '-',
                  ),
                ),
                Container(
                  width: 1,
                  height: 40,
                  color: colorScheme.outlineVariant.withAlpha(128),
                ),
                Expanded(
                  child: _buildRankItem(
                    colorScheme,
                    ap.departmentRank,
                    detail.departmentRank ?? '-',
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildMainItem(
    ColorScheme colorScheme,
    IconData icon,
    String label,
    String value,
  ) {
    return Column(
      children: <Widget>[
        Icon(icon, size: 28, color: colorScheme.primary),
        const SizedBox(height: 8),
        Text(
          value,
          style: TextStyle(
            fontSize: 28,
            fontWeight: FontWeight.bold,
            color: colorScheme.onPrimaryContainer,
          ),
        ),
        const SizedBox(height: 4),
        Text(
          label,
          style: TextStyle(
            fontSize: 13,
            color: colorScheme.onPrimaryContainer.withAlpha(179),
          ),
        ),
      ],
    );
  }

  Widget _buildRankItem(ColorScheme colorScheme, String label, String value) {
    return Column(
      children: <Widget>[
        Text(
          value,
          style: TextStyle(
            fontSize: 18,
            fontWeight: FontWeight.bold,
            color: colorScheme.onSurface,
          ),
        ),
        const SizedBox(height: 2),
        Text(
          label,
          style: TextStyle(fontSize: 12, color: colorScheme.onSurfaceVariant),
        ),
      ],
    );
  }

}

class ScoreAnalysis {
  ScoreAnalysis(this.scoreData) {
    _scores = <double>[];
    for (final Score score in scoreData.scores) {
      final double? value = double.tryParse(score.semesterScore ?? '');
      if (value != null) {
        _scores.add(value);
      }
    }
    _totalSubjects = _scores.length;
  }

  final ScoreData scoreData;
  late List<double> _scores;
  late int _totalSubjects;

  int get totalSubjects => _totalSubjects;

  double get maxScore => _scores.isEmpty ? 0 : _scores.reduce(max);

  double get minScore => _scores.isEmpty ? 0 : _scores.reduce(min);

  double get average {
    if (_scores.isEmpty) return 0;
    return _scores.reduce((double a, double b) => a + b) / _scores.length;
  }

  double get standardDeviation {
    if (_scores.isEmpty) return 0;
    final double avg = average;
    final double sumSquares = _scores.fold<double>(
      0,
      (double sum, double score) => sum + (score - avg) * (score - avg),
    );
    return sqrt(sumSquares / _scores.length);
  }

  int get estimatedPR {
    final double avg = scoreData.detail.average ?? average;
    if (avg >= 95) return 99;
    if (avg >= 90) return 95;
    if (avg >= 85) return 88;
    if (avg >= 80) return 78;
    if (avg >= 75) return 65;
    if (avg >= 70) return 50;
    if (avg >= 65) return 35;
    if (avg >= 60) return 22;
    if (avg >= 55) return 12;
    return 5;
  }

  String get prLevel {
    final int pr = estimatedPR;
    if (pr >= 90) return '頂尖';
    if (pr >= 75) return '優秀';
    if (pr >= 50) return '中等';
    if (pr >= 25) return '待加強';
    return '需努力';
  }

  Map<String, int> get distribution {
    final Map<String, int> dist = <String, int>{
      '90-100': 0,
      '80-89': 0,
      '70-79': 0,
      '60-69': 0,
      '0-59': 0,
    };

    for (final double score in _scores) {
      if (score >= 90) {
        dist['90-100'] = dist['90-100']! + 1;
      } else if (score >= 80) {
        dist['80-89'] = dist['80-89']! + 1;
      } else if (score >= 70) {
        dist['70-79'] = dist['70-79']! + 1;
      } else if (score >= 60) {
        dist['60-69'] = dist['60-69']! + 1;
      } else {
        dist['0-59'] = dist['0-59']! + 1;
      }
    }

    return dist;
  }

  double get totalCredits {
    double credits = 0;
    for (final Score score in scoreData.scores) {
      final double? unit = double.tryParse(score.units);
      if (unit != null) credits += unit;
    }
    return credits;
  }

  double get passedCredits {
    double credits = 0;
    for (final Score score in scoreData.scores) {
      final double? scoreValue = double.tryParse(score.semesterScore ?? '');
      final double? unit = double.tryParse(score.units);
      if (scoreValue != null && scoreValue >= 60 && unit != null) {
        credits += unit;
      }
    }
    return credits;
  }

  double get failedCredits {
    double credits = 0;
    for (final Score score in scoreData.scores) {
      final double? scoreValue = double.tryParse(score.semesterScore ?? '');
      final double? unit = double.tryParse(score.units);
      if (scoreValue != null && scoreValue < 60 && unit != null) {
        credits += unit;
      }
    }
    return credits;
  }
Const 建構子

UserInfoScaffold 的新 UI 實作中,許多 StatelessWidget 或具有固定屬性的 ContainerSizedBoxTextIcon 可以使用 const 建構子。這有助於 Flutter 在重建時優化性能,避免不必要的重繪。例如,_buildAvatar()_buildInfoCard()_buildBarcodeCard() 內部的一些固定元件,以及 _buildDivider() 等方法返回的元件。

                    const SizedBox(height: 40),
                    _buildAvatar(colorScheme, isDark),
                    const SizedBox(height: 12),
                    Text(
                      widget.userInfo.name ?? '',
                      style: TextStyle(
                        fontSize: 22,
                        fontWeight: FontWeight.bold,
                        color: isDark
                            ? colorScheme.onSurface
                            : colorScheme.onPrimary,
                      ),
                    ),
                  ],
                ),
              ),
            ),
          ),
          actions: <Widget>[
            ...widget.actions ?? <Widget>[],
            if (widget.enableBarCode)
              IconButton(
                icon: Image.asset(
                  iconName,
                  height: 24.0,
                  width: 24.0,
                ),
                onPressed: () {
                  setState(
                    () => codeMode = BarCodeMode.values[
                        (codeMode.index + 1) % BarCodeMode.values.length],
                  );
                  AnalyticsUtil.instance.logEvent('user_info_barcode_switch');
                },
              ),
            IconButton(
              icon: _isRefreshing
                  ? SizedBox(
                      width: 20,
                      height: 20,
                      child: CircularProgressIndicator(
                        strokeWidth: 2,
                        color: isDark
                            ? colorScheme.onSurface
                            : colorScheme.onPrimary,
                      ),
                    )
                  : const Icon(Icons.refresh_rounded),
              onPressed: _isRefreshing ? null : _handleRefresh,
            ),
          ],
        ),
        SliverToBoxAdapter(
          child: RefreshIndicator(
            onRefresh: () async {
              await _handleRefresh();
              AnalyticsUtil.instance.logEvent('user_info_refresh');
            },
            child: SingleChildScrollView(
              physics: const AlwaysScrollableScrollPhysics(),
              child: Padding(
                padding: const EdgeInsets.all(16),
                child: Column(
                  children: <Widget>[
                    _buildInfoCard(colorScheme),
                    if (widget.enableBarCode) ...<Widget>[
                      const SizedBox(height: 16),
                      _buildBarcodeCard(colorScheme),
                    ],
                  ],
                ),
              ),
            ),
          ),
        ),
      ],
    ),
  );
}

Widget _buildAvatar(ColorScheme colorScheme, bool isDark) {
  final bool hasImage = widget.userInfo.pictureBytes != null &&
      widget.userInfo.pictureBytes!.isNotEmpty;
  return DecoratedBox(
    decoration: BoxDecoration(
      shape: BoxShape.circle,
      border: Border.all(
        color: isDark
            ? colorScheme.primary
            : colorScheme.onPrimary.withAlpha(128),
        width: 4,
      ),
      boxShadow: <BoxShadow>[
        BoxShadow(
          color: colorScheme.shadow.withAlpha(51),
          blurRadius: 16,
          offset: const Offset(0, 6),
        ),
      ],
    ),
    child: CircleAvatar(
      radius: 48,
      backgroundColor: isDark
          ? colorScheme.primaryContainer
          : colorScheme.onPrimary.withAlpha(51),
      backgroundImage:
          hasImage ? MemoryImage(widget.userInfo.pictureBytes!) : null,
      child: hasImage
          ? null
          : Icon(
              Icons.person_rounded,
              size: 56,
              color: isDark ? colorScheme.primary : colorScheme.onPrimary,
            ),
    ),
  );
}

Widget _buildInfoCard(ColorScheme colorScheme) {
  return DecoratedBox(
    decoration: BoxDecoration(
      color: colorScheme.surfaceContainerLowest,
      borderRadius: BorderRadius.circular(16),
      border: Border.all(
        color: colorScheme.outlineVariant.withAlpha(77),
      ),
    ),
    child: Column(
      children: <Widget>[
        InfoRow(
          icon: Icons.badge_outlined,
          title: app.studentId,
          value: widget.userInfo.id,
        ),
        _buildDivider(colorScheme),
        if (widget.userInfo.educationSystem != null) ...<Widget>[
          InfoRow(
            icon: Icons.school_outlined,
            title: app.educationSystem,
            value: widget.userInfo.educationSystem!,
          ),
          _buildDivider(colorScheme),
        ],
        if (widget.userInfo.email != null) ...<Widget>[
          InfoRow(
            icon: Icons.email_outlined,
            title: app.email,
            value: widget.userInfo.email!,
          ),
          _buildDivider(colorScheme),
        ],
        InfoRow(
          icon: Icons.domain_outlined,
          title: app.department,
          value: widget.userInfo.department ?? '',
        ),
        _buildDivider(colorScheme),
        InfoRow(
          icon: Icons.class_outlined,
          title: app.studentClass,
          value: widget.userInfo.className ?? '',
        ),
      ],
    ),
  );
}

Widget _buildBarcodeCard(ColorScheme colorScheme) {
  return Container(
    decoration: BoxDecoration(
      color: colorScheme.surfaceContainerLowest,
      borderRadius: BorderRadius.circular(16),
      border: Border.all(
        color: colorScheme.outlineVariant.withAlpha(77),
      ),
    ),
    padding: const EdgeInsets.all(20),
    child: Column(
      children: <Widget>[
        Row(
          children: <Widget>[
            Icon(
              Icons.badge_outlined,
              color: colorScheme.primary,
            ),
            const SizedBox(width: 8),
            Text(
              '學號條碼',
              style: TextStyle(
                fontSize: 16,
                fontWeight: FontWeight.w600,
                color: colorScheme.onSurface,
              ),
            ),
          ],
        ),
        const SizedBox(height: 16),
        Container(
          width: double.infinity,
          padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 16),
          decoration: BoxDecoration(
            color: colorScheme.surfaceContainerHighest,
            borderRadius: BorderRadius.circular(12),
          ),
          child: Column(
            children: <Widget>[
              Text(
                widget.userInfo.id,
                style: TextStyle(
                  fontSize: 28,
                  fontWeight: FontWeight.bold,
                  color: colorScheme.onSurface,
                  letterSpacing: 4,
                  fontFamily: 'monospace',
                ),
              ),
              const SizedBox(height: 12),
              BarcodeWidget(
                barcode: codeMode == BarCodeMode.code39
                    ? Barcode.code39()
                    : Barcode.qrCode(),
                data: widget.userInfo.id,
                color: colorScheme.onSurface,
                height: codeMode == BarCodeMode.code39 ? 80 : 160,
                width: codeMode == BarCodeMode.code39 ? double.infinity : 160,
              ),
              const SizedBox(height: 12),
              Text(
                '可持本條碼於圖書館借書',
                style: TextStyle(
                  fontSize: 12,
                  color: colorScheme.onSurfaceVariant,
                ),
              ),
            ],
          ),
        ),
      ],
    ),
  );
}

Widget _buildDivider(ColorScheme colorScheme) {
  return Divider(
    height: 1,
    indent: 72,
    color: colorScheme.outlineVariant.withAlpha(77),
  );
}

@github-actions
Copy link

github-actions bot commented Mar 4, 2026

PR Code Suggestions ✨

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Performance
優化課程查找效能

_getCourseAt 方法在每次渲染課程表單元格時都會遍歷所有課程及其時間,這可能導致效能問題,尤其當課程數量龐大時。建議在 CourseScaffoldState
初始化或 widget.courseData 變更時,預先建立一個以 (weekday, timeIndex) 為鍵的查找表(例如 Map<int, Map<int,
Course>>),以將查找時間複雜度從 O(N) 降低到 O(1)。

packages/ap_common_flutter_ui/lib/src/scaffold/course_scaffold.dart [750-759]

-Course? _getCourseAt(int weekday, int timeIndex) {
+late Map<int, Map<int, Course>> _courseLookup;
+
+@override
+void initState() {
+  super.initState();
+  _buildCourseLookup();
+  // ... other init code
+}
+
+@override
+void didUpdateWidget(covariant CourseScaffold oldWidget) {
+  super.didUpdateWidget(oldWidget);
+  if (widget.courseData != oldWidget.courseData) {
+    _buildCourseLookup();
+  }
+}
+
+void _buildCourseLookup() {
+  _courseLookup = <int, Map<int, Course>>{};
   for (final Course course in widget.courseData.courses) {
     for (final SectionTime time in course.times) {
-      if (time.weekday == weekday && time.index == timeIndex) {
-        return course;
-      }
+      _courseLookup
+          .putIfAbsent(time.weekday, () => <int, Course>{})[time.index] = course;
     }
   }
-  return null;
 }
 
+Course? _getCourseAt(int weekday, int timeIndex) {
+  return _courseLookup[weekday]?[timeIndex];
+}
+
Suggestion importance[1-10]: 9

__

Why: The suggestion correctly identifies a potential performance bottleneck in the _getCourseAt method. Pre-building a lookup map will significantly reduce the time complexity for course lookups from O(N) to O(1), which is crucial for a frequently called function in a UI rendering loop.

High
General
避免靜態可變主題狀態

ApTheme 中的 currentColorIndexcustomColor 被定義為靜態可變變數,並在 ChangeThemeColorItem
中直接修改。這種做法可能導致 UI 更新不一致,因為 InheritedWidget 通常需要明確重建才能反應狀態變化。建議將這些主題狀態納入 ApTheme
實例中,或使用 ChangeNotifier 搭配 Provider 等狀態管理方案,以確保主題變更時能正確通知並重建相關元件。

packages/ap_common_flutter_ui/lib/src/resources/ap_theme.dart [61-63]

-static const int customColorIndex = -1;
-static int currentColorIndex = 0;
-static Color? customColor;
+// 建議將這些狀態移至一個專用的 ThemeProvider (ChangeNotifier)
+// 或在 ApTheme 內部管理,並在 updateShouldNotify 中檢查變化
Suggestion importance[1-10]: 8

__

Why: 使用靜態可變變數來管理 InheritedWidget 的狀態可能導致 UI 更新不一致。將主題狀態納入 ApTheme 實例或使用狀態管理方案會更健壯。

Medium
優化學期排序效能

_getSortedSemesters 方法中,int.tryParse
在排序比較函數內部被重複呼叫。對於較大的學期列表,這會引入不必要的效能開銷。建議在排序之前,先將 yearvalue
的整數值解析並儲存到一個臨時資料結構中,以優化排序效能。

packages/ap_common_flutter_ui/lib/src/widgets/semester_picker.dart [236-247]

-indexed.sort((MapEntry<int, Semester> a, MapEntry<int, Semester> b) {
-  final int yearA = int.tryParse(a.value.year) ?? 0;
-  final int yearB = int.tryParse(b.value.year) ?? 0;
-
-  if (yearA != yearB) {
-    return yearB.compareTo(yearA);
+List<MapEntry<int, Semester>> _getSortedSemesters() {
+  final List<_SemesterSortable> sortableSemesters = <_SemesterSortable>[];
+  for (int i = 0; i < semesterData.data.length; i++) {
+    final Semester semester = semesterData.data[i];
+    sortableSemesters.add(_SemesterSortable(
+      originalIndex: i,
+      semester: semester,
+      parsedYear: int.tryParse(semester.year) ?? 0,
+      parsedValue: _getSemesterSortValue(semester.value),
+    ));
   }
 
-  final int semA = _getSemesterSortValue(a.value.value);
-  final int semB = _getSemesterSortValue(b.value.value);
-  return semA.compareTo(semB);
-});
+  sortableSemesters.sort((_SemesterSortable a, _SemesterSortable b) {
+    if (a.parsedYear != b.parsedYear) {
+      return b.parsedYear.compareTo(a.parsedYear);
+    }
+    return a.parsedValue.compareTo(b.parsedValue);
+  });
 
+  return sortableSemesters
+      .map((_SemesterSortable s) => MapEntry(s.originalIndex, s.semester))
+      .toList();
+}
+
+// 定義一個輔助類別
+// class _SemesterSortable {
+//   const _SemesterSortable({
+//     required this.originalIndex,
+//     required this.semester,
+//     required this.parsedYear,
+//     required this.parsedValue,
+//   });
+//   final int originalIndex;
+//   final Semester semester;
+//   final int parsedYear;
+//   final int parsedValue;
+// }
+
Suggestion importance[1-10]: 7

__

Why: 重複呼叫 int.tryParse_getSemesterSortValue 在排序函數內部會造成效能開銷。預先計算並儲存這些值可以提高排序效率。

Medium
Security
限制圖片記憶體載入大小

MemoryImage(userInfo!.pictureBytes!) 直接將圖片位元組載入記憶體。如果 userInfo.pictureBytes
包含過大的資料(例如,惡意的高解析度圖片),可能導致記憶體消耗過多,進而影響應用程式效能甚至引發記憶體不足錯誤。建議在載入前對圖片位元組的大小進行驗證或限制,或考慮對其進行壓縮處理。

packages/ap_common_flutter_ui/lib/src/widgets/ap_drawer.dart [142-143]

-backgroundImage:
-            hasImage ? MemoryImage(userInfo!.pictureBytes!) : null,
+backgroundImage: hasImage
+    ? (userInfo!.pictureBytes!.length > 5 * 1024 * 1024 // 例如,限制為 5MB
+        ? null // 或顯示預設圖示,或先壓縮圖片
+        : MemoryImage(userInfo!.pictureBytes!))
+    : null,
Suggestion importance[1-10]: 8

__

Why: 直接載入未經檢查的圖片位元組可能導致記憶體耗盡。限制圖片大小或進行壓縮處理可以提高應用程式的穩定性和安全性。

Medium

@github-actions
Copy link

github-actions bot commented Mar 4, 2026

Visit the preview URL for this PR (updated for commit e063b42):

https://ap-common--document-preview-va4m2unb.web.app

(expires Mon, 23 Mar 2026 17:20:45 GMT)

🔥 via Firebase Hosting GitHub Action 🌎

Sign: 61ecbaa5d4e2786c745a5e21d2a66c533b7f072b

@abc873693
Copy link
Owner Author

/review

@github-actions
Copy link

Persistent review updated to latest commit fc778b5

@abc873693
Copy link
Owner Author

/review

@github-actions
Copy link

Persistent review updated to latest commit e4bf273

@abc873693
Copy link
Owner Author

/review

@github-actions
Copy link

Persistent review updated to latest commit 8c5319b

@abc873693 abc873693 force-pushed the feature/ap-common-ui-v2 branch from 8c5319b to 0769366 Compare March 15, 2026 17:23
@abc873693 abc873693 changed the base branch from master to develop March 15, 2026 17:24
@abc873693 abc873693 merged commit ce1ebb9 into develop Mar 18, 2026
3 checks passed
@abc873693 abc873693 deleted the feature/ap-common-ui-v2 branch March 18, 2026 17:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

第二代介面

1 participant