feat(reports,splash): separate transparency board data/graphs and add release update popup
This commit is contained in:
1 parent
9a47a61c55
commit
029629c349
7 files changed
+1253
-120
No files matched your search
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user