feat(reports,splash): separate transparency board data/graphs and add release update popup

This commit is contained in:
ssbg04 committed 2026-09-18 18:22:04 +08:00
1 parent 9a47a61c55
commit 029629c349
7 files changed
+1253 -120

No files matched your search

+12
View File
@@ -669,6 +669,18 @@ Files backup/restore - mirror mode or zip -->
- **Collapsible requirement list**: make the document requirement list in the modal collapsible/expandable so it does not take up excessive vertical space on smaller screens.
- **Responsive layout**: ensure the requirement list and its items resize/adapt properly across different screen widths (no overflow, no truncated text). -->
<!-- # REPORTS SCREEN
- separate the data & graph for deped transparency board tab:
- segmented view mode switcher (`Data Tables`, `Visual Graphs`, `Combined View`) in header banner.
- dedicated charts for Dropouts vs. Transferees and 4Ps Beneficiaries vs. Total Learners.
- section guards ensuring tabular data mode displays only tables, and visual graphs mode displays only charts.
# ADD "UPDATE POP UP" AFTER SPLASH SCREEN
- detect version based on installed app vs latest tag version from GitHub repo https://github.com/ssbg04/TIS_RMS.
- `AppUpdateService` queries latest release via GitHub API, compares semver (`PackageInfo`), and resolves platform asset (.exe / .apk).
- `UpdateAvailableDialog` with installed vs latest version pill, platform asset tag, release notes, and download link.
- `SplashScreen` check with 4-second timeout and non-blocking background error handling. -->
---
# DONT DO
@@ -0,0 +1,174 @@
import 'dart:io' show Platform;
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart' show debugPrint, kIsWeb;
import 'package:package_info_plus/package_info_plus.dart';
/// Structured information about an available app update.
class AppUpdateInfo {
final String currentVersion;
final String latestVersion;
final bool hasUpdate;
final String releaseTitle;
final String releaseNotes;
final String htmlUrl;
final String? downloadUrl;
final String? assetName;
final DateTime? publishedAt;
const AppUpdateInfo({
required this.currentVersion,
required this.latestVersion,
required this.hasUpdate,
required this.releaseTitle,
required this.releaseNotes,
required this.htmlUrl,
this.downloadUrl,
this.assetName,
this.publishedAt,
});
}
class AppUpdateService {
static const String _repoReleasesUrl =
'https://api.github.com/repos/ssbg04/TIS_RMS/releases/latest';
/// Fallback version if PackageInfo fails to resolve on platform.
static const String defaultFallbackVersion = '1.0.22';
/// Checks GitHub repository for the latest release tag and compares with installed version.
static Future<AppUpdateInfo?> checkForUpdate({
Duration timeout = const Duration(seconds: 5),
}) async {
try {
// 1. Get current installed app version
String currentVersion = defaultFallbackVersion;
String currentBuild = '0';
try {
final packageInfo = await PackageInfo.fromPlatform();
if (packageInfo.version.trim().isNotEmpty) {
currentVersion = packageInfo.version.trim();
}
if (packageInfo.buildNumber.trim().isNotEmpty) {
currentBuild = packageInfo.buildNumber.trim();
}
} catch (e) {
debugPrint('[AppUpdateService] PackageInfo failed, using fallback $defaultFallbackVersion: $e');
}
// 2. Fetch latest release from GitHub Releases API
final dio = Dio(
BaseOptions(
connectTimeout: timeout,
receiveTimeout: timeout,
headers: {
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'TIS_RMS_Client',
},
),
);
final response = await dio.get<Map<String, dynamic>>(_repoReleasesUrl);
if (response.statusCode != 200 || response.data == null) {
return null;
}
final data = response.data!;
final rawTagName = (data['tag_name'] as String? ?? '').trim();
if (rawTagName.isEmpty) return null;
final releaseTitle = (data['name'] as String? ?? rawTagName).trim();
final releaseNotes = (data['body'] as String? ?? '').trim();
final htmlUrl = (data['html_url'] as String? ?? 'https://github.com/ssbg04/TIS_RMS/releases').trim();
DateTime? publishedAt;
if (data['published_at'] != null) {
publishedAt = DateTime.tryParse(data['published_at'].toString());
}
// 3. Resolve platform-specific download asset
String? downloadUrl;
String? assetName;
final assets = data['assets'] as List<dynamic>? ?? [];
final isWindows = !kIsWeb && Platform.isWindows;
final isAndroid = !kIsWeb && Platform.isAndroid;
for (final asset in assets) {
if (asset is Map<String, dynamic>) {
final name = (asset['name'] as String? ?? '').toLowerCase();
final browserUrl = asset['browser_download_url'] as String?;
if (isWindows && name.endsWith('.exe')) {
downloadUrl = browserUrl;
assetName = asset['name'] as String?;
break;
} else if (isAndroid && name.endsWith('.apk')) {
downloadUrl = browserUrl;
assetName = asset['name'] as String?;
break;
}
}
}
// Fallback download URL to html release page if no specific asset found
downloadUrl ??= htmlUrl;
// 4. Compare versions
final hasUpdate = isRemoteNewer(
remoteTag: rawTagName,
currentVersion: currentVersion,
currentBuild: currentBuild,
);
return AppUpdateInfo(
currentVersion: currentVersion,
latestVersion: rawTagName,
hasUpdate: hasUpdate,
releaseTitle: releaseTitle,
releaseNotes: releaseNotes,
htmlUrl: htmlUrl,
downloadUrl: downloadUrl,
assetName: assetName,
publishedAt: publishedAt,
);
} catch (e) {
debugPrint('[AppUpdateService] Update check failed: $e');
return null;
}
}
/// Determines whether [remoteTag] (e.g. "v1.0.23") is newer than [currentVersion] (e.g. "1.0.22").
static bool isRemoteNewer({
required String remoteTag,
required String currentVersion,
String currentBuild = '0',
}) {
final cleanRemote = remoteTag.trim().replaceFirst(RegExp(r'^[vV]'), '');
final cleanCurrent = currentVersion.trim().replaceFirst(RegExp(r'^[vV]'), '');
final remoteParts = cleanRemote.split('+');
final currentParts = cleanCurrent.split('+');
final remoteSemVer = remoteParts[0].split('.');
final currentSemVer = currentParts[0].split('.');
for (int i = 0; i < 3; i++) {
final rNum = i < remoteSemVer.length ? int.tryParse(remoteSemVer[i]) ?? 0 : 0;
final cNum = i < currentSemVer.length ? int.tryParse(currentSemVer[i]) ?? 0 : 0;
if (rNum > cNum) return true;
if (rNum < cNum) return false;
}
// If semvers match, check build number if available
final remoteBuildNum = remoteParts.length > 1 ? int.tryParse(remoteParts[1]) : null;
final currentBuildNum = int.tryParse(currentBuild) ??
(currentParts.length > 1 ? int.tryParse(currentParts[1]) : null);
if (remoteBuildNum != null && currentBuildNum != null) {
return remoteBuildNum > currentBuildNum;
}
return false;
}
}
@@ -52,13 +52,20 @@ class _TransparencyBoardContent extends ConsumerStatefulWidget {
ConsumerState<_TransparencyBoardContent> createState() => _TransparencyBoardContentState();
}
enum TransparencyViewMode { tables, graphs, combined }
class _TransparencyBoardContentState extends ConsumerState<_TransparencyBoardContent> {
final ScrollController _enrollmentChartScrollController = ScrollController();
final ScrollController _dropoutChartScrollController = ScrollController();
final ScrollController _equity4PsChartScrollController = ScrollController();
bool _isExportingPdf = false;
TransparencyViewMode _viewMode = TransparencyViewMode.tables;
@override
void dispose() {
_enrollmentChartScrollController.dispose();
_dropoutChartScrollController.dispose();
_equity4PsChartScrollController.dispose();
super.dispose();
}
@@ -249,6 +256,7 @@ class _TransparencyBoardContentState extends ConsumerState<_TransparencyBoardCon
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
_buildViewModeSwitcher(context, isDark),
if (academicYears.isNotEmpty)
_buildYearSelector(
context,
@@ -278,8 +286,9 @@ class _TransparencyBoardContentState extends ConsumerState<_TransparencyBoardCon
),
),
),
_buildViewModeSwitcher(context, isDark),
if (academicYears.isNotEmpty) ...[
const SizedBox(width: 12),
const SizedBox(width: 10),
_buildYearSelector(
context,
academicYears: academicYears,
@@ -352,7 +361,9 @@ class _TransparencyBoardContentState extends ConsumerState<_TransparencyBoardCon
_buildSectionHeader(
context,
icon: Icons.bar_chart,
label: '1. Data on Enrollment',
label: _viewMode == TransparencyViewMode.graphs
? '1. Enrollment Trends & Analytics'
: '1. Data on Enrollment',
color: AppColors.primaryGreen,
),
const SizedBox(height: AppSizes.p16),
@@ -371,7 +382,9 @@ class _TransparencyBoardContentState extends ConsumerState<_TransparencyBoardCon
_buildSectionHeader(
context,
icon: Icons.trending_down,
label: '2. Dropouts & Transferees',
label: _viewMode == TransparencyViewMode.graphs
? '2. Dropouts & Transferees Trends'
: '2. Dropouts & Transferees',
color: Colors.redAccent,
),
const SizedBox(height: AppSizes.p16),
@@ -390,7 +403,9 @@ class _TransparencyBoardContentState extends ConsumerState<_TransparencyBoardCon
_buildSectionHeader(
context,
icon: Icons.family_restroom,
label: '3. 4Ps Beneficiaries',
label: _viewMode == TransparencyViewMode.graphs
? '3. 4Ps Beneficiaries Distribution'
: '3. 4Ps Beneficiaries',
color: Colors.deepPurple,
),
const SizedBox(height: AppSizes.p16),
@@ -481,6 +496,76 @@ class _TransparencyBoardContentState extends ConsumerState<_TransparencyBoardCon
);
}
Widget _buildViewModeSwitcher(BuildContext context, bool isDark) {
final modes = [
(mode: TransparencyViewMode.tables, icon: Icons.table_chart_outlined, label: 'Data Tables'),
(mode: TransparencyViewMode.graphs, icon: Icons.bar_chart_rounded, label: 'Visual Graphs'),
(mode: TransparencyViewMode.combined, icon: Icons.dashboard_customize_outlined, label: 'Combined'),
];
return Container(
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.25),
borderRadius: BorderRadius.circular(9),
border: Border.all(
color: Colors.white.withValues(alpha: 0.35),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: modes.map((m) {
final isSelected = _viewMode == m.mode;
return InkWell(
onTap: () {
if (_viewMode != m.mode) {
setState(() => _viewMode = m.mode);
}
},
borderRadius: BorderRadius.circular(7),
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeInOut,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: isSelected ? Colors.white : Colors.transparent,
borderRadius: BorderRadius.circular(7),
boxShadow: isSelected
? [
BoxShadow(
color: Colors.black.withValues(alpha: 0.18),
blurRadius: 4,
offset: const Offset(0, 1),
),
]
: null,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
m.icon,
size: 14,
color: isSelected ? AppColors.darkGreen : Colors.white,
),
const SizedBox(width: 5),
Text(
m.label,
style: TextStyle(
fontSize: 11.5,
fontWeight: isSelected ? FontWeight.bold : FontWeight.w500,
color: isSelected ? AppColors.darkGreen : Colors.white,
),
),
],
),
),
);
}).toList(),
),
);
}
// ── Shared Helpers ─────────────────────────────────────────────────────────
static Widget _buildDifferenceCell(int? diff, {required bool hasPreviousYear, required bool isDark}) {
@@ -700,62 +785,71 @@ class _TransparencyBoardContentState extends ConsumerState<_TransparencyBoardCon
final latestYear = years.last;
final previousYear = years.length > 1 ? years[years.length - 2] : null;
final showGraph = _viewMode == TransparencyViewMode.graphs || _viewMode == TransparencyViewMode.combined;
final showTable = _viewMode == TransparencyViewMode.tables || _viewMode == TransparencyViewMode.combined;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Enrollment Trends across School Years',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: isDark ? AppColors.darkTextPrimary : AppColors.textPrimary,
if (showGraph) ...[
Text(
'Enrollment Trends across School Years',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: isDark ? AppColors.darkTextPrimary : AppColors.textPrimary,
),
),
),
const SizedBox(height: AppSizes.p12),
LayoutBuilder(
builder: (context, constraints) {
final isNarrow = constraints.maxWidth < 720;
return SizedBox(
height: isNarrow ? 400 : 360,
child: _buildEnrollmentGroupedBarChart(context, years, isDark: isDark),
);
},
),
const SizedBox(height: AppSizes.p24),
const Divider(height: 1),
const SizedBox(height: AppSizes.p16),
Wrap(
alignment: WrapAlignment.spaceBetween,
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 12,
runSpacing: 8,
children: [
Text(
previousYear != null
? 'Data on Enrollment (SY ${previousYear.yearRange} vs. SY ${latestYear.yearRange})'
: 'Data on Enrollment (SY ${latestYear.yearRange})',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: isDark ? AppColors.darkTextPrimary : AppColors.textPrimary,
),
),
Text(
'Active SY Total: ${latestYear.enrollment.overallTotal.total}',
style: const TextStyle(
fontWeight: FontWeight.bold,
color: AppColors.primaryGreen,
),
),
const SizedBox(height: AppSizes.p12),
LayoutBuilder(
builder: (context, constraints) {
final isNarrow = constraints.maxWidth < 720;
return SizedBox(
height: isNarrow ? 400 : 360,
child: _buildEnrollmentGroupedBarChart(context, years, isDark: isDark),
);
},
),
if (showTable) ...[
const SizedBox(height: AppSizes.p24),
const Divider(height: 1),
const SizedBox(height: AppSizes.p16),
],
),
const SizedBox(height: AppSizes.p12),
_buildEnrollmentComparisonTable(
context,
latestYear: latestYear,
previousYear: previousYear,
isDark: isDark,
),
],
if (showTable) ...[
Wrap(
alignment: WrapAlignment.spaceBetween,
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 12,
runSpacing: 8,
children: [
Text(
previousYear != null
? 'Data on Enrollment (SY ${previousYear.yearRange} vs. SY ${latestYear.yearRange})'
: 'Data on Enrollment (SY ${latestYear.yearRange})',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: isDark ? AppColors.darkTextPrimary : AppColors.textPrimary,
),
),
Text(
'Active SY Total: ${latestYear.enrollment.overallTotal.total}',
style: const TextStyle(
fontWeight: FontWeight.bold,
color: AppColors.primaryGreen,
),
),
],
),
const SizedBox(height: AppSizes.p12),
_buildEnrollmentComparisonTable(
context,
latestYear: latestYear,
previousYear: previousYear,
isDark: isDark,
),
],
],
);
}
@@ -1458,77 +1552,99 @@ class _TransparencyBoardContentState extends ConsumerState<_TransparencyBoardCon
final isDark = Theme.of(context).brightness == Brightness.dark;
final grades = [7, 8, 9, 10, 11, 12];
final showGraph = _viewMode == TransparencyViewMode.graphs || _viewMode == TransparencyViewMode.combined;
final showTable = _viewMode == TransparencyViewMode.tables || _viewMode == TransparencyViewMode.combined;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Dropouts sub-section
Row(
children: [
const Icon(Icons.error_outline, color: Colors.redAccent, size: 18),
const SizedBox(width: 8),
Text(
'Dropouts by Grade',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: isDark ? AppColors.darkTextPrimary : AppColors.textPrimary,
),
if (showGraph) ...[
Text(
'Dropouts vs. Transferees Trends across Grades',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: isDark ? AppColors.darkTextPrimary : AppColors.textPrimary,
),
),
const SizedBox(height: AppSizes.p12),
_buildDropoutTransfereeBarChart(context, years, isDark: isDark),
if (showTable) ...[
const SizedBox(height: AppSizes.p24),
const Divider(height: 1),
const SizedBox(height: AppSizes.p16),
],
),
const SizedBox(height: 12),
_buildMultiYearGradeTable(
context,
years: years,
grades: grades,
isDark: isDark,
getValue: (y, g) {
final row = y.dropouts.grades.firstWhere(
(item) => item.gradeLevel == g,
orElse: () => GradeDropoutCount(gradeLevel: g, droppedCount: 0),
);
return row.droppedCount;
},
getTotal: (y) => y.dropouts.totalDropped,
headerColor: Colors.red.withValues(alpha: 0.08),
totalColor: Colors.redAccent,
),
const SizedBox(height: 28),
// Transferees sub-section
Row(
children: [
const Icon(Icons.swap_horiz_outlined, color: Colors.orange, size: 18),
const SizedBox(width: 8),
Text(
'Transferees by Grade',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: isDark ? AppColors.darkTextPrimary : AppColors.textPrimary,
],
if (showTable) ...[
// Dropouts sub-section
Row(
children: [
const Icon(Icons.error_outline, color: Colors.redAccent, size: 18),
const SizedBox(width: 8),
Text(
'Dropouts by Grade',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: isDark ? AppColors.darkTextPrimary : AppColors.textPrimary,
),
),
),
],
),
const SizedBox(height: 12),
_buildMultiYearGradeTable(
context,
years: years,
grades: grades,
isDark: isDark,
getValue: (y, g) {
final row = y.transferees.grades.firstWhere(
(item) => item.gradeLevel == g,
orElse: () => GradeTransfereeCount(
gradeLevel: g,
transferredCount: 0,
],
),
const SizedBox(height: 12),
_buildMultiYearGradeTable(
context,
years: years,
grades: grades,
isDark: isDark,
getValue: (y, g) {
final row = y.dropouts.grades.firstWhere(
(item) => item.gradeLevel == g,
orElse: () => GradeDropoutCount(gradeLevel: g, droppedCount: 0),
);
return row.droppedCount;
},
getTotal: (y) => y.dropouts.totalDropped,
headerColor: Colors.red.withValues(alpha: 0.08),
totalColor: Colors.redAccent,
),
const SizedBox(height: 28),
// Transferees sub-section
Row(
children: [
const Icon(Icons.swap_horiz_outlined, color: Colors.orange, size: 18),
const SizedBox(width: 8),
Text(
'Transferees by Grade',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: isDark ? AppColors.darkTextPrimary : AppColors.textPrimary,
),
),
);
return row.transferredCount;
},
getTotal: (y) => y.transferees.totalTransferred,
headerColor: Colors.orange.withValues(alpha: 0.1),
totalColor: Colors.orange.shade800,
),
],
),
const SizedBox(height: 12),
_buildMultiYearGradeTable(
context,
years: years,
grades: grades,
isDark: isDark,
getValue: (y, g) {
final row = y.transferees.grades.firstWhere(
(item) => item.gradeLevel == g,
orElse: () => GradeTransfereeCount(
gradeLevel: g,
transferredCount: 0,
),
);
return row.transferredCount;
},
getTotal: (y) => y.transferees.totalTransferred,
headerColor: Colors.orange.withValues(alpha: 0.1),
totalColor: Colors.orange.shade800,
),
],
],
);
}
@@ -1661,11 +1777,31 @@ class _TransparencyBoardContentState extends ConsumerState<_TransparencyBoardCon
final currentLabel = 'SY ${latestYear.yearRange}';
final active4Ps = latestYear.fourPs;
final showGraph = _viewMode == TransparencyViewMode.graphs || _viewMode == TransparencyViewMode.combined;
final showTable = _viewMode == TransparencyViewMode.tables || _viewMode == TransparencyViewMode.combined;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
if (showGraph) ...[
Text(
'4Ps Beneficiaries Distribution across Grades (SY ${latestYear.yearRange})',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: isDark ? AppColors.darkTextPrimary : AppColors.textPrimary,
),
),
const SizedBox(height: AppSizes.p12),
_buildEquity4PsBarChart(context, years, isDark: isDark),
if (showTable) ...[
const SizedBox(height: AppSizes.p24),
const Divider(height: 1),
const SizedBox(height: AppSizes.p16),
],
],
if (showTable) ...[
Wrap(
alignment: WrapAlignment.spaceBetween,
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 12,
@@ -2207,6 +2343,380 @@ class _TransparencyBoardContentState extends ConsumerState<_TransparencyBoardCon
),
),
),
],
],
);
}
// ── Charts for Sections 2 & 3 ──────────────────────────────────────────────
Widget _buildDropoutTransfereeBarChart(
BuildContext context,
List<YearlyTransparencyItem> years, {
required bool isDark,
}) {
final grades = [7, 8, 9, 10, 11, 12];
final latestYear = years.last;
double maxVal = 10;
for (final g in grades) {
final dropRow = latestYear.dropouts.grades.firstWhere(
(item) => item.gradeLevel == g,
orElse: () => GradeDropoutCount(gradeLevel: g, droppedCount: 0),
);
final transRow = latestYear.transferees.grades.firstWhere(
(item) => item.gradeLevel == g,
orElse: () => GradeTransfereeCount(gradeLevel: g, transferredCount: 0),
);
if (dropRow.droppedCount > maxVal) maxVal = dropRow.droppedCount.toDouble();
if (transRow.transferredCount > maxVal) maxVal = transRow.transferredCount.toDouble();
}
return LayoutBuilder(
builder: (context, constraints) {
const double minChartWidth = 680.0;
final bool isNarrow = constraints.maxWidth < minChartWidth;
final double chartWidth = isNarrow ? minChartWidth : constraints.maxWidth;
final chartWidget = Padding(
padding: const EdgeInsets.only(top: 16, right: 14, left: 4),
child: SizedBox(
width: chartWidth,
child: BarChart(
BarChartData(
alignment: BarChartAlignment.spaceAround,
groupsSpace: 20,
maxY: (maxVal * 1.35).ceilToDouble(),
barTouchData: BarTouchData(
enabled: true,
handleBuiltInTouches: true,
touchTooltipData: BarTouchTooltipData(
fitInsideHorizontally: true,
fitInsideVertically: true,
getTooltipItem: (group, groupIndex, rod, rodIndex) {
final grade = grades[group.x.toInt()];
final isDrop = rodIndex == 0;
final type = isDrop ? 'Dropouts' : 'Transferees';
final count = rod.toY.toInt();
return BarTooltipItem(
'Grade $grade\n$type: $count',
const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
);
},
),
),
titlesData: FlTitlesData(
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 34,
getTitlesWidget: (val, meta) {
final idx = val.toInt();
if (idx < 0 || idx >= grades.length) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
'Grade ${grades[idx]}',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary,
),
),
);
},
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 38,
getTitlesWidget: (val, meta) {
if (val == 0) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(right: 6),
child: Text(
val.toInt().toString(),
textAlign: TextAlign.right,
style: TextStyle(
fontSize: 11,
color: isDark ? AppColors.darkTextMuted : Colors.grey,
),
),
);
},
),
),
),
gridData: FlGridData(
show: true,
drawVerticalLine: false,
getDrawingHorizontalLine: (val) => FlLine(
color: isDark ? AppColors.darkBorder : Colors.grey.withValues(alpha: 0.15),
strokeWidth: 1,
),
),
borderData: FlBorderData(show: false),
barGroups: List.generate(grades.length, (idx) {
final g = grades[idx];
final dropCount = latestYear.dropouts.grades.firstWhere(
(item) => item.gradeLevel == g,
orElse: () => GradeDropoutCount(gradeLevel: g, droppedCount: 0),
).droppedCount.toDouble();
final transCount = latestYear.transferees.grades.firstWhere(
(item) => item.gradeLevel == g,
orElse: () => GradeTransfereeCount(gradeLevel: g, transferredCount: 0),
).transferredCount.toDouble();
return BarChartGroupData(
x: idx,
barsSpace: 4,
barRods: [
BarChartRodData(
toY: dropCount,
color: Colors.redAccent.shade200,
width: isNarrow ? 12 : 15,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(3),
topRight: Radius.circular(3),
),
),
BarChartRodData(
toY: transCount,
color: Colors.orangeAccent,
width: isNarrow ? 12 : 15,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(3),
topRight: Radius.circular(3),
),
),
],
);
}),
),
),
),
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Wrap(
alignment: WrapAlignment.end,
spacing: 16,
runSpacing: 6,
children: [
_buildLegendItem('Dropouts (SY ${latestYear.yearRange})', Colors.redAccent.shade200, isDark),
_buildLegendItem('Transferees (SY ${latestYear.yearRange})', Colors.orangeAccent, isDark),
],
),
const SizedBox(height: 10),
SizedBox(
height: isNarrow ? 360 : 320,
child: isNarrow
? Scrollbar(
controller: _dropoutChartScrollController,
thumbVisibility: true,
child: SingleChildScrollView(
controller: _dropoutChartScrollController,
scrollDirection: Axis.horizontal,
child: chartWidget,
),
)
: chartWidget,
),
],
);
},
);
}
Widget _buildEquity4PsBarChart(
BuildContext context,
List<YearlyTransparencyItem> years, {
required bool isDark,
}) {
final grades = [7, 8, 9, 10, 11, 12];
final latestYear = years.last;
double maxVal = 10;
for (final g in grades) {
final item = latestYear.fourPs.grades.firstWhere(
(e) => e.gradeLevel == g,
orElse: () => Grade4PsCount(gradeLevel: g, fourPsCount: 0, totalStudents: 0, percentage: 0.0),
);
if (item.totalStudents > maxVal) maxVal = item.totalStudents.toDouble();
}
return LayoutBuilder(
builder: (context, constraints) {
const double minChartWidth = 680.0;
final bool isNarrow = constraints.maxWidth < minChartWidth;
final double chartWidth = isNarrow ? minChartWidth : constraints.maxWidth;
final chartWidget = Padding(
padding: const EdgeInsets.only(top: 16, right: 14, left: 4),
child: SizedBox(
width: chartWidth,
child: BarChart(
BarChartData(
alignment: BarChartAlignment.spaceAround,
groupsSpace: 20,
maxY: (maxVal * 1.3).ceilToDouble(),
barTouchData: BarTouchData(
enabled: true,
handleBuiltInTouches: true,
touchTooltipData: BarTouchTooltipData(
fitInsideHorizontally: true,
fitInsideVertically: true,
getTooltipItem: (group, groupIndex, rod, rodIndex) {
final grade = grades[group.x.toInt()];
final is4Ps = rodIndex == 0;
final type = is4Ps ? '4Ps Beneficiaries' : 'Total Students';
final count = rod.toY.toInt();
return BarTooltipItem(
'Grade $grade\n$type: $count',
const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
);
},
),
),
titlesData: FlTitlesData(
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 34,
getTitlesWidget: (val, meta) {
final idx = val.toInt();
if (idx < 0 || idx >= grades.length) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
'Grade ${grades[idx]}',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary,
),
),
);
},
),
),
),
gridData: FlGridData(
show: true,
drawVerticalLine: false,
getDrawingHorizontalLine: (val) => FlLine(
color: isDark ? AppColors.darkBorder : Colors.grey.withValues(alpha: 0.15),
strokeWidth: 1,
),
),
borderData: FlBorderData(show: false),
barGroups: List.generate(grades.length, (idx) {
final g = grades[idx];
final item = latestYear.fourPs.grades.firstWhere(
(e) => e.gradeLevel == g,
orElse: () => Grade4PsCount(gradeLevel: g, fourPsCount: 0, totalStudents: 0, percentage: 0.0),
);
return BarChartGroupData(
x: idx,
barsSpace: 4,
barRods: [
BarChartRodData(
toY: item.fourPsCount.toDouble(),
color: Colors.deepPurpleAccent,
width: isNarrow ? 12 : 15,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(3),
topRight: Radius.circular(3),
),
),
BarChartRodData(
toY: item.totalStudents.toDouble(),
color: Colors.teal.shade300,
width: isNarrow ? 12 : 15,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(3),
topRight: Radius.circular(3),
),
),
],
);
}),
),
),
),
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Wrap(
alignment: WrapAlignment.end,
spacing: 16,
runSpacing: 6,
children: [
_buildLegendItem('4Ps Beneficiaries (SY ${latestYear.yearRange})', Colors.deepPurpleAccent, isDark),
_buildLegendItem('Total Learners (SY ${latestYear.yearRange})', Colors.teal.shade300, isDark),
],
),
const SizedBox(height: 10),
SizedBox(
height: isNarrow ? 360 : 320,
child: isNarrow
? Scrollbar(
controller: _equity4PsChartScrollController,
thumbVisibility: true,
child: SingleChildScrollView(
controller: _equity4PsChartScrollController,
scrollDirection: Axis.horizontal,
child: chartWidget,
),
)
: chartWidget,
),
],
);
},
);
}
static Widget _buildLegendItem(String label, Color color, bool isDark) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(3),
),
),
const SizedBox(width: 6),
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary,
),
),
],
);
}
@@ -14,6 +14,8 @@ import '../../providers/auth_provider.dart';
import '../../../core/services/foreground_sync_service.dart';
import '../../shared/widgets/abstract_background.dart';
import '../../shared/widgets/app_button_loader.dart';
import '../../../core/services/app_update_service.dart';
import '../../shared/dialogs/update_available_dialog.dart';
class SplashScreen extends ConsumerStatefulWidget {
const SplashScreen({super.key});
@@ -85,6 +87,24 @@ class _SplashScreenState extends ConsumerState<SplashScreen>
final user = await ref.read(authProvider.notifier).tryAutoLogin();
if (!mounted) return;
// ── Step 3: Check for updates from GitHub repository ────────────────────
try {
final updateInfo = await AppUpdateService.checkForUpdate().timeout(
const Duration(seconds: 4),
onTimeout: () => null,
);
if (updateInfo != null && updateInfo.hasUpdate && mounted) {
await showUpdateAvailableDialog(
context,
updateInfo: updateInfo,
);
}
} catch (e) {
// Non-fatal, allow regular app navigation
}
if (!mounted) return;
if (user != null) {
final isDesktop = MediaQuery.of(context).size.width >= 800;
Navigator.of(context).pushReplacement(
@@ -0,0 +1,400 @@
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../../core/constants/app_colors.dart';
import '../../../core/services/app_update_service.dart';
import '../../../core/services/haptic_service.dart';
import '../../../core/services/sound_service.dart';
/// Displays a rich, modern dialog notifying the user of an available app update.
Future<void> showUpdateAvailableDialog(
BuildContext context, {
required AppUpdateInfo updateInfo,
VoidCallback? onDismiss,
}) async {
try {
SoundService.playInfo();
HapticService.info();
} catch (_) {}
await showDialog<void>(
context: context,
barrierDismissible: true,
builder: (dialogContext) {
final isDark = Theme.of(dialogContext).brightness == Brightness.dark;
final isWindows = !kIsWeb && Platform.isWindows;
final isAndroid = !kIsWeb && Platform.isAndroid;
final platformLabel = isWindows
? 'Windows Desktop Client'
: (isAndroid ? 'Android Universal App' : 'TIS RMS Client');
final assetLabel = updateInfo.assetName ??
(isWindows ? 'TIS_RMS_Client_Setup.exe' : 'TIS_RMS_Android_Universal.apk');
return Dialog(
backgroundColor: isDark ? AppColors.darkSurfaceCard : Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
elevation: 16,
insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480, maxHeight: 580),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// ── Header Banner ───────────────────────────────────────
Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18),
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [
AppColors.primaryGreen,
AppColors.darkGreen,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.only(
topLeft: Radius.circular(20),
topRight: Radius.circular(20),
),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.rocket_launch_rounded,
color: Colors.white,
size: 26,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'NEW UPDATE AVAILABLE',
style: TextStyle(
color: Colors.white70,
fontSize: 11,
fontWeight: FontWeight.w600,
letterSpacing: 1.0,
),
),
const SizedBox(height: 2),
Text(
updateInfo.releaseTitle.isNotEmpty
? updateInfo.releaseTitle
: 'TIS RMS ${updateInfo.latestVersion}',
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
),
// ── Body ────────────────────────────────────────────────
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Version Comparison Pill
Container(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 10,
),
decoration: BoxDecoration(
color: isDark
? AppColors.darkSurface2
: const Color(0xFFF1F5F9),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isDark
? AppColors.darkBorder
: Colors.grey.withValues(alpha: 0.25),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Installed Version',
style: TextStyle(
fontSize: 11,
color: isDark
? AppColors.darkTextMuted
: AppColors.textSecondary,
),
),
const SizedBox(height: 2),
Text(
'v${updateInfo.currentVersion.replaceFirst(RegExp(r'^[vV]'), '')}',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: isDark
? AppColors.darkTextSecondary
: Colors.grey.shade700,
),
),
],
),
const Icon(
Icons.arrow_forward_rounded,
size: 18,
color: AppColors.primaryGreen,
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'Latest Version',
style: TextStyle(
fontSize: 11,
color: isDark
? AppColors.darkTextMuted
: AppColors.textSecondary,
),
),
const SizedBox(height: 2),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: AppColors.primaryGreen
.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(6),
),
child: Text(
updateInfo.latestVersion.startsWith('v')
? updateInfo.latestVersion
: 'v${updateInfo.latestVersion}',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: AppColors.primaryGreen,
),
),
),
],
),
],
),
),
const SizedBox(height: 16),
// Platform package tag
Row(
children: [
Icon(
isWindows
? Icons.desktop_windows_outlined
: (isAndroid
? Icons.android_outlined
: Icons.devices_outlined),
size: 15,
color: AppColors.primaryGreen,
),
const SizedBox(width: 6),
Text(
'$platformLabel: ',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: isDark
? AppColors.darkTextSecondary
: AppColors.textSecondary,
),
),
Expanded(
child: Text(
assetLabel,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: isDark ? Colors.white : Colors.black87,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 16),
// Release Notes Header
Text(
'What\'s New:',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: isDark
? AppColors.darkTextPrimary
: AppColors.textPrimary,
),
),
const SizedBox(height: 8),
// Release Notes Container
Container(
width: double.infinity,
constraints: const BoxConstraints(maxHeight: 180),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: isDark
? AppColors.darkSurface2
: const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isDark
? AppColors.darkBorder
: Colors.grey.withValues(alpha: 0.2),
),
),
child: SingleChildScrollView(
child: Text(
updateInfo.releaseNotes.isNotEmpty
? updateInfo.releaseNotes
: 'A new version of TIS RMS is available with bug fixes, performance improvements, and enhanced security updates.',
style: TextStyle(
fontSize: 12.5,
height: 1.45,
color: isDark
? AppColors.darkTextSecondary
: Colors.black87,
),
),
),
),
],
),
),
),
// ── Action Buttons ──────────────────────────────────────
Container(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 14,
),
decoration: BoxDecoration(
border: Border(
top: BorderSide(
color: isDark
? AppColors.darkBorder
: Colors.grey.withValues(alpha: 0.15),
),
),
),
child: Row(
children: [
// Remind me later button
Expanded(
flex: 4,
child: OutlinedButton(
onPressed: () {
Navigator.of(dialogContext).pop();
onDismiss?.call();
},
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
side: BorderSide(
color: isDark
? AppColors.darkBorder
: Colors.grey.shade300,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
child: Text(
'Later',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: isDark
? AppColors.darkTextSecondary
: AppColors.textSecondary,
),
),
),
),
const SizedBox(width: 12),
// Update Now button
Expanded(
flex: 6,
child: ElevatedButton.icon(
onPressed: () async {
final url = updateInfo.downloadUrl ?? updateInfo.htmlUrl;
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(
uri,
mode: LaunchMode.externalApplication,
);
}
if (dialogContext.mounted) {
Navigator.of(dialogContext).pop();
}
onDismiss?.call();
},
icon: const Icon(
Icons.download_rounded,
color: Colors.white,
size: 18,
),
label: const Text(
'Download Update',
style: TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.bold,
),
),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryGreen,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
),
),
],
),
),
],
),
),
);
},
);
}
+16
View File
@@ -912,6 +912,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
package_info_plus:
dependency: "direct main"
description:
name: package_info_plus
sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968"
url: "https://pub.dev"
source: hosted
version: "8.3.1"
package_info_plus_platform_interface:
dependency: transitive
description:
name: package_info_plus_platform_interface
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
url: "https://pub.dev"
source: hosted
version: "3.2.1"
path:
dependency: transitive
description:
+1
View File
@@ -70,6 +70,7 @@ dependencies:
vibration: ^3.2.0
desktop_drop: ^0.7.1
audioplayers: ^6.8.1
package_info_plus: ^8.1.0
dev_dependencies:
flutter_test: