diff --git a/.github/workflows/android_build.yml b/.github/workflows/android_build.yml new file mode 100644 index 0000000..c536989 --- /dev/null +++ b/.github/workflows/android_build.yml @@ -0,0 +1,45 @@ +name: Build Android APK + +on: + push: + branches: + - main + - ios-pwa + workflow_dispatch: # Allows manual trigger from GitHub UI + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: '17' + cache: 'gradle' + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + channel: 'stable' + cache: true + + - name: Install Dependencies + run: flutter pub get + + - name: Create dart_defines.json + run: echo '${{ secrets.DART_DEFINES_JSON }}' > dart_defines.json + + - name: Build APK + run: flutter build apk --release --dart-define-from-file=dart_defines.json + + - name: Upload APK Artifact + uses: actions/upload-artifact@v4 + with: + name: schedulify-release-apk + path: build/app/outputs/flutter-apk/app-release.apk + retention-days: 7 diff --git a/ios/Podfile b/ios/Podfile index a4f4fc5..4ab0475 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -platform :ios, '15.0' +platform :ios, '16.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' @@ -41,7 +41,8 @@ post_install do |installer| installer.pods_project.targets.each do |target| flutter_additional_ios_build_settings(target) target.build_configurations.each do |config| - config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0' + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '16.0' + config.build_settings['EXCLUDED_ARCHS[sdk=iphonesimulator*]'] = 'arm64' end end end diff --git a/ios/Podfile.lock b/ios/Podfile.lock index cd58742..b4e92dc 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,16 +1,29 @@ PODS: + - barcode_scan2 (0.0.1): + - Flutter + - SwiftProtobuf (~> 1.33) - Flutter (1.0.0) + - SwiftProtobuf (1.38.0) DEPENDENCIES: + - barcode_scan2 (from `.symlinks/plugins/barcode_scan2/ios`) - Flutter (from `Flutter`) +SPEC REPOS: + trunk: + - SwiftProtobuf + EXTERNAL SOURCES: + barcode_scan2: + :path: ".symlinks/plugins/barcode_scan2/ios" Flutter: :path: Flutter SPEC CHECKSUMS: + barcode_scan2: 4e4b850b112f4e29017833e4715f36161f987966 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + SwiftProtobuf: d724b5145bfc609d9a49c1e3e3a3dabb07273ffb -PODFILE CHECKSUM: 5b09406f5533f670a98a35b5bf281f156d27c54b +PODFILE CHECKSUM: ba4b7dd263cdb42897a2433345744d9e6a9917e4 COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index da2ff1f..eb0e849 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -205,6 +205,7 @@ 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 05E86B82B9FAE641430CFEF1 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -282,6 +283,23 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 05E86B82B9FAE641430CFEF1 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -443,6 +461,7 @@ ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -451,7 +470,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -566,6 +585,7 @@ ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -580,7 +600,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -623,6 +643,7 @@ ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -631,7 +652,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; diff --git a/lib/core/utils/pwa_prompt.dart b/lib/core/utils/pwa_prompt.dart new file mode 100644 index 0000000..4227fc0 --- /dev/null +++ b/lib/core/utils/pwa_prompt.dart @@ -0,0 +1,162 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +// Conditional import: only the web variant accesses dart:js_interop. +import 'pwa_prompt_stub.dart' + if (dart.library.js_interop) 'pwa_prompt_web.dart'; + +/// Shows an "Add to Home Screen" bottom sheet after login, +/// but ONLY when running as a web app on iOS Safari (not yet installed as PWA). +/// +/// Usage: call [PwaPrompt.showIfNeeded] after navigating post-login. +class PwaPrompt { + PwaPrompt._(); + + static bool _alreadyShown = false; + + static Future showIfNeeded(BuildContext context) async { + if (!kIsWeb) return; + if (_alreadyShown) return; + if (!PwaDetector.shouldShow()) return; + + _alreadyShown = true; + await Future.delayed(const Duration(milliseconds: 700)); + if (!context.mounted) return; + + await showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (_) => const _InstallHintSheet(), + ); + } +} + +class _InstallHintSheet extends StatelessWidget { + const _InstallHintSheet(); + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Container( + margin: const EdgeInsets.fromLTRB(16, 0, 16, 16), + decoration: BoxDecoration( + color: const Color(0xFF1C1C2E), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: Colors.white.withAlpha(20)), + ), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Drag handle + Container( + width: 40, height: 4, + margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration( + color: Colors.white24, + borderRadius: BorderRadius.circular(2), + ), + ), + // Header row + Row(children: [ + Container( + width: 48, height: 48, + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF6C63FF), Color(0xFF3B82F6)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon(Icons.schedule_rounded, + color: Colors.white, size: 26), + ), + const SizedBox(width: 16), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Install Schedulify', + style: TextStyle( + color: Colors.white, + fontSize: 17, + fontWeight: FontWeight.w700)), + SizedBox(height: 2), + Text('Get the full app experience on iOS', + style: TextStyle(color: Colors.white54, fontSize: 13)), + ], + ), + ), + ]), + const SizedBox(height: 20), + const _StepRow( + icon: Icons.ios_share_rounded, + text: 'Tap the Share button', + sub: 'at the bottom of your browser bar', + ), + const SizedBox(height: 12), + const _StepRow( + icon: Icons.add_box_outlined, + text: 'Tap "Add to Home Screen"', + sub: 'then tap Add in the top right', + ), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + child: TextButton( + onPressed: () => Navigator.of(context).pop(), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), + backgroundColor: Colors.white.withAlpha(15), + ), + child: const Text('Maybe later', + style: TextStyle(color: Colors.white70, fontSize: 15)), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _StepRow extends StatelessWidget { + final IconData icon; + final String text; + final String sub; + const _StepRow( + {required this.icon, required this.text, required this.sub}); + + @override + Widget build(BuildContext context) { + return Row(children: [ + Container( + width: 40, height: 40, + decoration: BoxDecoration( + color: Colors.white.withAlpha(13), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, color: Colors.white70, size: 20), + ), + const SizedBox(width: 14), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(text, + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w600)), + Text(sub, + style: const TextStyle(color: Colors.white38, fontSize: 12)), + ], + ), + ]); + } +} diff --git a/lib/core/utils/pwa_prompt_stub.dart b/lib/core/utils/pwa_prompt_stub.dart new file mode 100644 index 0000000..3079514 --- /dev/null +++ b/lib/core/utils/pwa_prompt_stub.dart @@ -0,0 +1,6 @@ +/// Stub used on non-web platforms. +/// On native (Android/iOS) this class always returns false, +/// so the PWA prompt is never shown. +class PwaDetector { + static bool shouldShow() => false; +} diff --git a/lib/core/utils/pwa_prompt_web.dart b/lib/core/utils/pwa_prompt_web.dart new file mode 100644 index 0000000..fc5eedd --- /dev/null +++ b/lib/core/utils/pwa_prompt_web.dart @@ -0,0 +1,15 @@ +import 'dart:js_interop'; + +@JS('window._schedulifyShowPwaPrompt') +external JSBoolean? get _showPwaPrompt; + +/// Web implementation: reads the flag set by the inline JS in index.html. +class PwaDetector { + static bool shouldShow() { + try { + return _showPwaPrompt?.toDart ?? false; + } catch (_) { + return false; + } + } +} diff --git a/lib/features/auth/screens/login_screen.dart b/lib/features/auth/screens/login_screen.dart index d587079..94fdab6 100644 --- a/lib/features/auth/screens/login_screen.dart +++ b/lib/features/auth/screens/login_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../../core/theme/app_theme.dart'; import '../../../core/providers/auth_provider.dart'; +import '../../../core/utils/pwa_prompt.dart'; import '../../../shared/widgets/widgets.dart'; class LoginScreen extends ConsumerStatefulWidget { @@ -42,6 +43,8 @@ class _LoginScreenState extends ConsumerState { _ => '/student', }; context.go(route); + // Show iOS PWA install hint (no-op on Android native & desktop). + await PwaPrompt.showIfNeeded(context); } } diff --git a/lib/features/student/screens/scanner_screen.dart b/lib/features/student/screens/scanner_screen.dart index 4f90290..e938829 100644 --- a/lib/features/student/screens/scanner_screen.dart +++ b/lib/features/student/screens/scanner_screen.dart @@ -1,8 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:barcode_scan2/barcode_scan2.dart'; -import 'package:permission_handler/permission_handler.dart'; import '../../../core/providers/auth_provider.dart'; import '../../../core/theme/app_theme.dart'; import '../../../models/attendance_models.dart'; @@ -10,6 +8,7 @@ import '../../../services/attendance_service.dart'; import '../../../services/geofence_service.dart'; import '../../../services/qr_hash_service.dart'; import '../../../shared/widgets/widgets.dart'; +import 'package:barcode_scan2/barcode_scan2.dart'; class ScannerScreen extends ConsumerStatefulWidget { const ScannerScreen({super.key}); @@ -104,34 +103,6 @@ class _ScannerScreenState extends ConsumerState { Future _requestCameraAndScan() async { if (!mounted) return; - - var status = await Permission.camera.status; - if (!status.isGranted) { - status = await Permission.camera.request(); - } - - if (!mounted) return; - - if (status.isPermanentlyDenied) { - setState(() { - _step = _Step.error; - _errorMessage = - 'Camera permission permanently denied.\n\n' - 'Go to Settings → Apps → Schedulify → Permissions → Camera → Allow,\n' - 'then tap Try Again.'; - }); - return; - } - - if (!status.isGranted) { - setState(() { - _step = _Step.error; - _errorMessage = - 'Camera permission is required to scan the QR code.\nTap Try Again to allow it.'; - }); - return; - } - setState(() => _step = _Step.scanning); try { @@ -146,12 +117,19 @@ class _ScannerScreenState extends ConsumerState { return; } - await _submitAttendance(result.rawContent); + if (result.type == ResultType.Barcode) { + await _submitAttendance(result.rawContent); + } else { + setState(() { + _step = _Step.error; + _errorMessage = 'Could not read barcode. Try again.'; + }); + } } catch (e) { if (!mounted) return; setState(() { _step = _Step.error; - _errorMessage = 'Scanner error: $e\n\nTap Try Again.'; + _errorMessage = 'Scanner error: $e'; }); } } @@ -213,11 +191,10 @@ class _ScannerScreenState extends ConsumerState { appBar: AppBar(title: const Text('Mark Attendance')), body: switch (_step) { _Step.checking => _CheckingView(), - _Step.scanning => _ScanningView(), + _Step.scanning => const Center(child: CircularProgressIndicator()), _Step.error => _ErrorView( message: _errorMessage ?? 'Unknown error', - onRetry: _doGeofenceCheck, - showSettings: _errorMessage?.contains('permanently') ?? false), + onRetry: _doGeofenceCheck), _Step.result => _ResultView( success: _success, submitting: _submitting, @@ -242,27 +219,12 @@ class _CheckingView extends StatelessWidget { ); } -class _ScanningView extends StatelessWidget { - @override - Widget build(BuildContext context) => const Center( - child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ - CircularProgressIndicator(), - SizedBox(height: 24), - Text('Opening camera…', - style: TextStyle(color: AppColors.textPrimary, fontSize: 16)), - SizedBox(height: 8), - Text('Point at the QR code on the faculty screen', - style: TextStyle(color: AppColors.textSecondary, fontSize: 13)), - ]), - ); -} +// QrScannerView is removed, barcode_scan2 handles its own UI class _ErrorView extends StatelessWidget { final String message; final VoidCallback onRetry; - final bool showSettings; - const _ErrorView({required this.message, required this.onRetry, - this.showSettings = false}); + const _ErrorView({required this.message, required this.onRetry}); @override Widget build(BuildContext context) => Center( @@ -293,14 +255,6 @@ class _ErrorView extends StatelessWidget { width: double.infinity, onPressed: onRetry, ), - if (showSettings) ...[ - const SizedBox(height: 12), - OutlinedButton.icon( - onPressed: openAppSettings, - icon: const Icon(Icons.settings_rounded, size: 16), - label: const Text('Open App Settings'), - ), - ], ]), ), ); diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..379a867 --- /dev/null +++ b/vercel.json @@ -0,0 +1,15 @@ +{ + "rewrites": [ + { "source": "/(.*)", "destination": "/index.html" } + ], + "headers": [ + { + "source": "/flutter_bootstrap.js", + "headers": [{ "key": "Cache-Control", "value": "no-cache" }] + }, + { + "source": "/(.*\\.js|.*\\.wasm)", + "headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }] + } + ] +} diff --git a/web/index.html b/web/index.html index 3b3dc1f..620416f 100644 --- a/web/index.html +++ b/web/index.html @@ -1,46 +1,43 @@ - + - - - + + - - - - + + + + + + + + + + + + - schedulify + Schedulify - + diff --git a/web/manifest.json b/web/manifest.json index 6efb1e2..7616508 100644 --- a/web/manifest.json +++ b/web/manifest.json @@ -1,12 +1,13 @@ { - "name": "schedulify", - "short_name": "schedulify", - "start_url": ".", + "name": "Schedulify", + "short_name": "Schedulify", + "description": "Multi-tenant college scheduling platform", + "start_url": "/", + "scope": "/", "display": "standalone", - "background_color": "#0175C2", - "theme_color": "#0175C2", - "description": "A new Flutter project.", "orientation": "portrait-primary", + "background_color": "#0D0D14", + "theme_color": "#0D0D14", "prefer_related_applications": false, "icons": [ {