Compare commits

..

No commits in common. "999947f55924619c610d7cdd55c4928f1ad15edb" and "c9188a4e313b7bb353e429cb26e7b7b012b8785a" have entirely different histories.

12 changed files with 245 additions and 1081 deletions

View File

@ -1,58 +1,2 @@
# Wesal App # Wesal
A social app to connect with your colleagues within the Computer Operations Department (COD)
A social networking mobile application designed for connecting workplace colleagues. Wesal (Arabic for "connection") enhances communication and collaboration among division members.
## Features
- **Social Feed**: Create, view, like, and comment on posts
- **Invitations**: Create and manage event invitations with RSVP functionality
- **Daily Puzzles**: Timed daily challenges with leaderboards (9AM-11AM)
- **Push Notifications**: Stay engaged with real-time updates
## Quick Start with Docker
1. **Clone and setup**:
```bash
git clone https://git.bubshait.me/sBubshait/wesal.git
cd wesal
chmod +x install.py
sudo ./install.py
```
2. **Run the application**:
```bash
docker compose up --build
```
3. **Access the services**:
- Frontend: http://localhost:6060
- Backend API: http://localhost:4044
- API Docs: http://localhost:4044/docs
- Database Admin: http://localhost:8100
## Technology Stack
- **Frontend**: Flutter 3.8.1+
- **Backend**: Spring Boot 3.5.3 (Java 21)
- **Database**: PostgreSQL
- **Authentication**: JWT tokens
- **Notifications**: Firebase Cloud Messaging
- **Deployment**: Docker containerization
## Documentation
For detailed technical documentation, installation guides, development setup, and troubleshooting, see:
📖 **[Complete Documentation](docs/docs.pdf)**
## Development
See the [full documentation](docs/docs.tex) for:
- Manual setup instructions
- Development commands
- Testing procedures
- API documentation
- Troubleshooting guide
## Support
Contact COD/DPSD for application support and maintenance information.

View File

@ -4,14 +4,11 @@ import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableAsync;
import java.util.TimeZone;
@SpringBootApplication @SpringBootApplication
@EnableAsync @EnableAsync
public class WesalApplication { public class WesalApplication {
public static void main(String[] args) { public static void main(String[] args) {
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Riyadh"));
SpringApplication.run(WesalApplication.class, args); SpringApplication.run(WesalApplication.class, args);
} }

View File

@ -16,7 +16,6 @@ import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@ -70,9 +69,10 @@ public class PuzzleController {
@GetMapping("/leaderboard") @GetMapping("/leaderboard")
@Operation(summary = "Get daily leaderboard", description = "Returns leaderboard with 9am cutoff logic (before 9am shows yesterday, from 9am shows today)") @Operation(summary = "Get daily leaderboard", description = "Returns leaderboard with 9am cutoff logic (before 9am shows yesterday, from 9am shows today)")
public ResponseEntity<ApiResponse<List<LeaderboardEntry>>> getLeaderboard() { public ResponseEntity<ApiResponse<Map<String, Object>>> getLeaderboard() {
try { try {
List<PuzzleAttempt> attempts = puzzleService.getTodaysLeaderboard(); List<PuzzleAttempt> attempts = puzzleService.getTodaysLeaderboard();
String leaderboardInfo = puzzleService.getLeaderboardDateInfo();
List<LeaderboardEntry> leaderboard = attempts.stream() List<LeaderboardEntry> leaderboard = attempts.stream()
.filter(attempt -> attempt.isSolved()) .filter(attempt -> attempt.isSolved())
@ -86,7 +86,12 @@ public class PuzzleController {
)) ))
.collect(Collectors.toList()); .collect(Collectors.toList());
return ResponseEntity.ok(ApiResponse.success(leaderboard)); Map<String, Object> response = Map.of(
"leaderboard", leaderboard,
"dateInfo", leaderboardInfo
);
return ResponseEntity.ok(ApiResponse.success(response));
} catch (RuntimeException e) { } catch (RuntimeException e) {
return ResponseEntity.ok(ApiResponse.error(e.getMessage())); return ResponseEntity.ok(ApiResponse.error(e.getMessage()));
} }

View File

@ -172,4 +172,15 @@ public class PuzzleService {
return puzzleAttemptRepository.findByPuzzleOrderBySubmittedAtAsc(puzzleOpt.get()); return puzzleAttemptRepository.findByPuzzleOrderBySubmittedAtAsc(puzzleOpt.get());
} }
public String getLeaderboardDateInfo() {
LocalDateTime riyadhNow = getRiyadhNow();
LocalDate leaderboardDate = getLeaderboardDate();
LocalDate today = riyadhNow.toLocalDate();
if (leaderboardDate.equals(today)) {
return "Today's Leaderboard (from 9:00 AM Riyadh time)";
} else {
return "Yesterday's Leaderboard (before 9:00 AM Riyadh time)";
}
}
} }

Binary file not shown.

View File

@ -194,15 +194,6 @@ The database schema is shown below.
\captionof{figure}{Database Schema} \captionof{figure}{Database Schema}
\end{center} \end{center}
\newpage
\subsection{Landing Page}
The landing page of the Wesal application is a simple and elegant page that provides an overview of the application and its features. It is designed to be visually appealing and easy to navigate. A screenshot of the landing page is shown below.
\begin{center}
\includegraphics[width=0.8\textwidth]{landingPage.png}
\captionof{figure}{Landing Page}
\end{center}
\newpage \newpage
\section{Installation and Setup} \section{Installation and Setup}
\subsection{Source Code} \subsection{Source Code}
@ -549,19 +540,6 @@ This most of the times happen when the token has expired but Flutter for some re
Make sure you have followed the installation steps in this guide as both the front end and backend require a Service Account from Google Firebase to work. If was working but stopped working that is because (1) the user disabled the notifications, (2) the user skipped the notifications accepting in creation of the account. Make sure you have followed the installation steps in this guide as both the front end and backend require a Service Account from Google Firebase to work. If was working but stopped working that is because (1) the user disabled the notifications, (2) the user skipped the notifications accepting in creation of the account.
\end{warningbox} \end{warningbox}
\subsubsection{Seeing an old version of the app}
\begin{warningbox}
\textbf{Problem:} User sees an old version of the app or changes are not reflected\\
\textbf{Solution:}
Unfourtunately, this is one of the limitations of PWAs. Because this is not actually installed as a real app (for the time being), it is treated as a normal website. Browsers cache (save a version of) the app to enhance performance and reduce loading times. This is especially true for Safari on iOS devices. Safari caches aggressively and does not always update the app when changes are made.
One can confirm the issue by opening the app in an incognito window. Long term solution for this issue includes either releasing the app to the App Store or using Versioning and Updating Service Worker on a newer version in JS.
The work around currently however is to simply clear the browser cache. As this problem is notable on iOS devices, the steps are shown below for iOS:
\textbf{iOS:} Delete the current application. Open Settings > Safari > Advanced > Website Data > Search for "wesal.online" or the hostname of the app > Swipe left and tap Delete. Reinstall the application.
\end{warningbox}
\subsection{Backend Optimization} \subsection{Backend Optimization}
\begin{itemize}[leftmargin=*] \begin{itemize}[leftmargin=*]
\item Use database indexing for frequently queried fields \item Use database indexing for frequently queried fields

View File

@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'dart:async';
import 'wordle_page.dart'; import 'wordle_page.dart';
import '../../services/puzzle_service.dart'; import '../../services/puzzle_service.dart';
import '../../services/user_service.dart'; import '../../services/user_service.dart';
@ -17,8 +16,6 @@ class _PuzzlesPageState extends State<PuzzlesPage> {
DailyChallenge? _dailyChallenge; DailyChallenge? _dailyChallenge;
bool _isLoading = false; bool _isLoading = false;
String? _errorMessage; String? _errorMessage;
Timer? _timer;
DateTime _currentTime = DateTime.now();
void _showLeaderboardView() { void _showLeaderboardView() {
setState(() { setState(() {
@ -36,51 +33,6 @@ class _PuzzlesPageState extends State<PuzzlesPage> {
void initState() { void initState() {
super.initState(); super.initState();
_loadDailyChallenge(); _loadDailyChallenge();
_updateCurrentTime();
// Update time every minute
_timer = Timer.periodic(Duration(minutes: 1), (timer) {
_updateCurrentTime();
});
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
void _updateCurrentTime() {
setState(() {
_currentTime = DateTime.now();
});
}
DateTime get _currentTimeUTC3 {
// Convert current time to UTC+3 (Riyadh timezone)
final utc = _currentTime.toUtc();
return utc.add(Duration(hours: 3));
}
bool get _isGameAvailable {
final timeUTC3 = _currentTimeUTC3;
final hour = timeUTC3.hour;
return hour >= 9 && hour < 20; // 9 AM to 11 AM UTC+3
}
String get _gameStatusText {
final timeUTC3 = _currentTimeUTC3;
final hour = timeUTC3.hour;
final minute = timeUTC3.minute;
final currentTimeFormatted =
'${hour.toString().padLeft(2, '0')}:${minute.toString().padLeft(2, '0')}';
if (_isGameAvailable) {
return 'Game Available Now!';
} else if (hour < 9) {
return 'Daily Challenge starts at 09:00';
} else {
return 'Daily Challenge ended at 11:00';
}
} }
Future<void> _loadDailyChallenge() async { Future<void> _loadDailyChallenge() async {
@ -104,11 +56,6 @@ class _PuzzlesPageState extends State<PuzzlesPage> {
} }
void _startGame() async { void _startGame() async {
if (!_isGameAvailable) {
_showGameNotAvailableDialog();
return;
}
if (_dailyChallenge == null) { if (_dailyChallenge == null) {
_loadDailyChallenge(); _loadDailyChallenge();
return; return;
@ -128,35 +75,6 @@ class _PuzzlesPageState extends State<PuzzlesPage> {
}); });
} }
void _showGameNotAvailableDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('Game Not Available'),
content: Text(
'The Daily Challenge is only available between 09:00 and 11:00.\n\n'
'You can still view the leaderboard to see today\'s results!',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text('OK', style: TextStyle(color: Color(0xFF6A4C93))),
),
TextButton(
onPressed: () {
Navigator.of(context).pop();
_showLeaderboardView();
},
child: Text(
'View Leaderboard',
style: TextStyle(color: Color(0xFF6A4C93)),
),
),
],
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (_currentView == 'leaderboard') { if (_currentView == 'leaderboard') {
@ -204,61 +122,37 @@ class _PuzzlesPageState extends State<PuzzlesPage> {
), ),
SizedBox(height: 16), SizedBox(height: 16),
// Challenge timing info with live status // Challenge timing info
Container( Container(
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12), padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: (_isGameAvailable ? Colors.green : Colors.orange) color: Colors.white.withOpacity(0.2),
.withOpacity(0.3),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
border: Border.all(
color: _isGameAvailable
? Colors.green.shade300
: Colors.orange.shade300,
width: 1,
), ),
), child: Text(
child: Column( 'Daily Wordle Challenge Open Until 11 AM',
children: [
Text(
'Daily Challenge: 09:00 - 11:00',
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
color: Colors.white, color: Colors.white,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 4),
Text(
_gameStatusText,
style: TextStyle(
fontSize: 14,
color: Colors.white,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
textAlign: TextAlign.center,
),
],
), ),
), ),
SizedBox(height: 60), SizedBox(height: 60),
// Big gamified PLAY button with conditional availability // Big gamified PLAY button
GestureDetector( GestureDetector(
onTap: _isLoading ? null : _startGame, onTap: _isLoading ? null : _startGame,
child: Container( child: Container(
padding: EdgeInsets.symmetric(horizontal: 40, vertical: 20), padding: EdgeInsets.symmetric(horizontal: 40, vertical: 20),
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
colors: _isGameAvailable colors: [Colors.white, Colors.white.withOpacity(0.9)],
? [Colors.white, Colors.white.withOpacity(0.9)]
: [Colors.grey[300]!, Colors.grey[400]!],
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
), ),
borderRadius: BorderRadius.circular(25), borderRadius: BorderRadius.circular(25),
boxShadow: _isGameAvailable boxShadow: [
? [
BoxShadow( BoxShadow(
color: Colors.black.withOpacity(0.3), color: Colors.black.withOpacity(0.3),
blurRadius: 20, blurRadius: 20,
@ -269,18 +163,9 @@ class _PuzzlesPageState extends State<PuzzlesPage> {
blurRadius: 10, blurRadius: 10,
offset: Offset(-5, -5), offset: Offset(-5, -5),
), ),
]
: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 5,
offset: Offset(0, 2),
),
], ],
border: Border.all( border: Border.all(
color: _isGameAvailable color: Color(0xFF6A4C93).withOpacity(0.2),
? Color(0xFF6A4C93).withOpacity(0.2)
: Colors.grey.withOpacity(0.3),
width: 2, width: 2,
), ),
), ),
@ -288,13 +173,9 @@ class _PuzzlesPageState extends State<PuzzlesPage> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon( Icon(
_isGameAvailable Icons.sports_esports,
? Icons.sports_esports
: Icons.access_time,
size: 32, size: 32,
color: _isGameAvailable color: Color(0xFF6A4C93),
? Color(0xFF6A4C93)
: Colors.grey[600],
), ),
SizedBox(width: 12), SizedBox(width: 12),
_isLoading _isLoading
@ -304,36 +185,26 @@ class _PuzzlesPageState extends State<PuzzlesPage> {
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 2, strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>( valueColor: AlwaysStoppedAnimation<Color>(
_isGameAvailable Color(0xFF6A4C93),
? Color(0xFF6A4C93)
: Colors.grey[600]!,
), ),
), ),
) )
: Text( : Text(
!_isGameAvailable _dailyChallenge?.attempted == true
? 'GAME CLOSED'
: _dailyChallenge?.attempted == true
? 'VIEW RESULTS' ? 'VIEW RESULTS'
: 'PLAY NOW', : 'PLAY NOW',
style: TextStyle( style: TextStyle(
fontSize: 24, fontSize: 24,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: _isGameAvailable color: Color(0xFF6A4C93),
? Color(0xFF6A4C93)
: Colors.grey[600],
letterSpacing: 1.5, letterSpacing: 1.5,
), ),
), ),
SizedBox(width: 8), SizedBox(width: 8),
Icon( Icon(
_isGameAvailable Icons.arrow_forward_ios,
? Icons.arrow_forward_ios
: Icons.lock,
size: 20, size: 20,
color: _isGameAvailable color: Color(0xFF6A4C93),
? Color(0xFF6A4C93)
: Colors.grey[600],
), ),
], ],
), ),
@ -366,11 +237,7 @@ class _PuzzlesPageState extends State<PuzzlesPage> {
Container( Container(
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12), padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: color: (_dailyChallenge!.solved ? Colors.green : Colors.orange).withOpacity(0.2),
(_dailyChallenge!.solved
? Colors.green
: Colors.orange)
.withOpacity(0.2),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
), ),
child: Text( child: Text(
@ -387,27 +254,6 @@ class _PuzzlesPageState extends State<PuzzlesPage> {
), ),
], ],
// Game availability help text
if (!_isGameAvailable) ...[
SizedBox(height: 20),
Container(
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12),
decoration: BoxDecoration(
color: Colors.blue.withOpacity(0.2),
borderRadius: BorderRadius.circular(20),
),
child: Text(
'The Daily Challenge is available every day from 09:00 to 11:00 Riyadh time. You can still view the leaderboard!',
style: TextStyle(
fontSize: 14,
color: Colors.white,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
),
],
SizedBox(height: 40), SizedBox(height: 40),
// Leaderboard button // Leaderboard button
@ -532,8 +378,7 @@ class _LeaderboardContentState extends State<_LeaderboardContent> {
if (leaderboardResponse.status) { if (leaderboardResponse.status) {
_leaderboardData = leaderboardResponse.data; _leaderboardData = leaderboardResponse.data;
} else { } else {
_errorMessage = _errorMessage = leaderboardResponse.message ?? 'Failed to load leaderboard';
leaderboardResponse.message ?? 'Failed to load leaderboard';
} }
}); });
} catch (e) { } catch (e) {
@ -557,7 +402,10 @@ class _LeaderboardContentState extends State<_LeaderboardContent> {
SizedBox(height: 16), SizedBox(height: 16),
Text( Text(
'Loading leaderboard...', 'Loading leaderboard...',
style: TextStyle(fontSize: 16, color: Colors.grey[600]), style: TextStyle(
fontSize: 16,
color: Colors.grey[600],
),
), ),
], ],
), ),
@ -569,11 +417,18 @@ class _LeaderboardContentState extends State<_LeaderboardContent> {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(Icons.error_outline, size: 64, color: Colors.grey[400]), Icon(
Icons.error_outline,
size: 64,
color: Colors.grey[400],
),
SizedBox(height: 16), SizedBox(height: 16),
Text( Text(
_errorMessage!, _errorMessage!,
style: TextStyle(fontSize: 16, color: Colors.grey[600]), style: TextStyle(
fontSize: 16,
color: Colors.grey[600],
),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
SizedBox(height: 16), SizedBox(height: 16),
@ -612,7 +467,10 @@ class _LeaderboardContentState extends State<_LeaderboardContent> {
SizedBox(height: 8), SizedBox(height: 8),
Text( Text(
'Be the first one!', 'Be the first one!',
style: TextStyle(fontSize: 16, color: Colors.grey[500]), style: TextStyle(
fontSize: 16,
color: Colors.grey[500],
),
), ),
], ],
), ),
@ -658,9 +516,7 @@ class _LeaderboardContentState extends State<_LeaderboardContent> {
margin: EdgeInsets.only(bottom: 12), margin: EdgeInsets.only(bottom: 12),
padding: EdgeInsets.all(16), padding: EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isCurrentUser color: isCurrentUser ? Color(0xFF6A4C93).withOpacity(0.05) : Colors.white,
? Color(0xFF6A4C93).withOpacity(0.05)
: Colors.white,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
@ -702,8 +558,7 @@ class _LeaderboardContentState extends State<_LeaderboardContent> {
CircleAvatar( CircleAvatar(
radius: 25, radius: 25,
backgroundColor: Color(0xFF6A4C93).withOpacity(0.1), backgroundColor: Color(0xFF6A4C93).withOpacity(0.1),
backgroundImage: backgroundImage: entry.avatar != null && entry.avatar!.isNotEmpty
entry.avatar != null && entry.avatar!.isNotEmpty
? NetworkImage(entry.avatar!) ? NetworkImage(entry.avatar!)
: null, : null,
child: entry.avatar == null || entry.avatar!.isEmpty child: entry.avatar == null || entry.avatar!.isEmpty
@ -739,10 +594,7 @@ class _LeaderboardContentState extends State<_LeaderboardContent> {
if (isCurrentUser) ...[ if (isCurrentUser) ...[
SizedBox(width: 8), SizedBox(width: 8),
Container( Container(
padding: EdgeInsets.symmetric( padding: EdgeInsets.symmetric(horizontal: 6, vertical: 2),
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Color(0xFF6A4C93), color: Color(0xFF6A4C93),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),

View File

@ -383,19 +383,9 @@ class _WordlePageState extends State<WordlePage> with TickerProviderStateMixin {
child: Container(height: 1, color: Colors.grey[200]), child: Container(height: 1, color: Colors.grey[200]),
), ),
), ),
body: SafeArea( body: Column(
child: LayoutBuilder(
builder: (context, constraints) {
double screenHeight = constraints.maxHeight;
double keyboardHeight = (gameWon || gameLost) ? 200 : 200; // Estimated heights
double availableHeight = screenHeight - keyboardHeight;
return Column(
children: [ children: [
// Game Grid Section
Expanded( Expanded(
child: Container(
height: availableHeight,
child: Center( child: Center(
child: Container( child: Container(
constraints: BoxConstraints(maxWidth: 350), constraints: BoxConstraints(maxWidth: 350),
@ -521,26 +511,14 @@ class _WordlePageState extends State<WordlePage> with TickerProviderStateMixin {
), ),
), ),
), ),
), // Keyboard or completion message
// Spacer between grid and keyboard
SizedBox(height: 16),
// Keyboard or completion message section
Container( Container(
constraints: BoxConstraints( padding: EdgeInsets.all(8),
minHeight: 200,
maxHeight: 250,
),
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 16),
child: (gameWon || gameLost) child: (gameWon || gameLost)
? _buildCompletionMessage() ? _buildCompletionMessage()
: _buildKeyboard(), : _buildKeyboard(),
), ),
// Bottom padding to ensure keyboard isn't at the very bottom
SizedBox(height: MediaQuery.of(context).padding.bottom + 8),
], ],
);
},
),
), ),
), ),
), ),

View File

@ -163,11 +163,10 @@ class NotificationService {
} }
void _showNotification(RemoteNotification notification) { void _showNotification(RemoteNotification notification) {
// For web PWA, we rely on the service worker to show notifications // For web, we rely on the service worker to show notifications
// This method is only for logging since service worker handles the UI // This is mainly for logging and debugging
print('Foreground notification received - Title: ${notification.title}'); print('Notification Title: ${notification.title}');
print('Foreground notification received - Body: ${notification.body}'); print('Notification Body: ${notification.body}');
// Note: Do not show browser notification here as service worker handles it
} }
void _handleNotificationTap(RemoteMessage message) { void _handleNotificationTap(RemoteMessage message) {
@ -308,15 +307,12 @@ class NotificationService {
final payload = { final payload = {
'message': { 'message': {
'token': token, 'token': token,
// Use data-only for PWA to prevent duplicate notifications 'notification': {'title': title, 'body': body},
'data': { 'data': data ?? {},
'title': title,
'body': body,
...(data ?? {}),
},
'webpush': { 'webpush': {
'headers': { 'notification': {
'Urgency': 'normal', 'icon': 'icons/ios/192.png',
'badge': 'icons/ios/192.png',
}, },
}, },
}, },
@ -370,15 +366,12 @@ class NotificationService {
final payload = { final payload = {
'message': { 'message': {
'topic': topic, 'topic': topic,
// Use data-only for PWA to prevent duplicate notifications 'notification': {'title': title, 'body': body},
'data': { 'data': data ?? {},
'title': title,
'body': body,
...(data ?? {}),
},
'webpush': { 'webpush': {
'headers': { 'notification': {
'Urgency': 'normal', 'icon': 'icons/ios/192.png',
'badge': 'icons/ios/192.png',
}, },
}, },
}, },

View File

@ -18,16 +18,13 @@ firebase.initializeApp(firebaseConfig);
// Initialize Firebase Messaging // Initialize Firebase Messaging
const messaging = firebase.messaging(); const messaging = firebase.messaging();
// Handle background messages (data-only notifications) // Handle background messages
messaging.onBackgroundMessage((payload) => { messaging.onBackgroundMessage((payload) => {
console.log('Received background message ', payload); console.log('Received background message ', payload);
// For data-only messages, title and body are in payload.data const notificationTitle = payload.notification?.title || 'Wesal App';
const notificationTitle = payload.data?.title || payload.notification?.title || 'Wesal App';
const notificationBody = payload.data?.body || payload.notification?.body || 'You have a new notification';
const notificationOptions = { const notificationOptions = {
body: notificationBody, body: payload.notification?.body || 'You have a new notification',
icon: '/icons/Icon-192.png', icon: '/icons/Icon-192.png',
badge: '/icons/Icon-192.png', badge: '/icons/Icon-192.png',
data: payload.data, data: payload.data,
@ -76,7 +73,29 @@ self.addEventListener('notificationclick', (event) => {
); );
}); });
// Note: Removed duplicate push event handler - Firebase onBackgroundMessage handles this // Handle push events
self.addEventListener('push', (event) => {
console.log('Push event received:', event);
if (event.data) {
const data = event.data.json();
console.log('Push data:', data);
const notificationTitle = data.notification?.title || 'Wesal App';
const notificationOptions = {
body: data.notification?.body || 'You have a new notification',
icon: '/icons/Icon-192.png',
badge: '/icons/Icon-192.png',
data: data.data,
tag: data.data?.type || 'general',
requireInteraction: true,
};
event.waitUntil(
self.registration.showNotification(notificationTitle, notificationOptions)
);
}
});
// Handle service worker activation // Handle service worker activation
self.addEventListener('activate', (event) => { self.addEventListener('activate', (event) => {

View File

@ -1,613 +0,0 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Wesal - وصال</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
@font-face {
font-family: 'Blaka';
src: url('assets/fonts/Blaka-Regular.ttf') format('truetype');
font-weight: 200;
font-display: swap;
}
body {
font-family: 'Inter', sans-serif;
line-height: 1.6;
color: #333;
overflow-x: hidden;
}
.hero-section {
min-height: 100vh;
background: linear-gradient(180deg, #32B0A5 0%, #4600B9 100%);
display: flex;
align-items: center;
padding: 2rem;
position: relative;
}
.hero-section::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(45deg, rgba(255, 255, 255, 0.1) 0%, transparent 100%);
pointer-events: none;
}
.hero-container {
max-width: 1400px;
width: 100%;
margin: 0 auto;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4rem;
align-items: center;
z-index: 2;
position: relative;
}
.hero-content {
text-align: left;
}
.app-title {
font-family: 'Blaka', 'Inter', sans-serif;
font-weight: 200;
font-size: clamp(3rem, 6vw, 5rem);
color: white;
margin-bottom: 1rem;
text-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
letter-spacing: 2px;
}
.app-subtitle {
font-size: clamp(1.4rem, 2.5vw, 2rem);
color: rgba(255, 255, 255, 0.95);
margin-bottom: 3rem;
font-weight: 300;
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
line-height: 1.4;
}
.mockup-container {
display: flex;
justify-content: center;
align-items: center;
}
.mockup-image {
width: 100%;
height: auto;
border-radius: 24px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
transition: transform 0.3s ease;
/* Responsive sizing with larger desktop maximum */
max-width: 400px;
}
.mockup-image:hover {
transform: translateY(-10px);
}
/* Desktop-specific sizing for larger screens */
@media (min-width: 1200px) {
.mockup-image {
max-width: 600px;
/* Larger on desktop */
}
}
@media (min-width: 1440px) {
.mockup-image {
max-width: 700px;
/* Even larger on very wide screens */
}
}
.download-btn {
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(10px);
border: 2px solid rgba(255, 255, 255, 0.2);
color: white;
padding: 1.2rem 3rem;
font-size: 1.2rem;
font-weight: 600;
border-radius: 50px;
cursor: pointer;
transition: all 0.3s ease;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.download-btn:hover {
background: rgba(255, 255, 255, 0.25);
transform: translateY(-2px);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
}
.download-icon {
width: 20px;
height: 20px;
}
.scroll-indicator {
position: absolute;
bottom: 2rem;
left: 50%;
transform: translateX(-50%);
color: rgba(255, 255, 255, 0.7);
animation: bounce 2s infinite;
}
@keyframes bounce {
0%,
20%,
50%,
80%,
100% {
transform: translateX(-50%) translateY(0);
}
40% {
transform: translateX(-50%) translateY(-10px);
}
60% {
transform: translateX(-50%) translateY(-5px);
}
}
.about-section {
padding: 6rem 2rem;
background: white;
max-width: 1200px;
margin: 0 auto;
}
.section-title {
font-size: 3rem;
color: #6A4C93;
text-align: center;
margin-bottom: 2rem;
font-weight: 700;
}
.about-description {
text-align: center;
margin-bottom: 4rem;
max-width: 700px;
margin-left: auto;
margin-right: auto;
}
.about-description p {
font-size: 1.3rem;
color: #666;
margin-bottom: 1.5rem;
line-height: 1.7;
}
.features-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 2.5rem;
margin-bottom: 3rem;
}
.feature-card {
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
padding: 2.5rem;
border-radius: 20px;
text-align: center;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.08);
border: 1px solid #e9ecef;
transition: transform 0.3s ease;
}
.feature-card:hover {
transform: translateY(-5px);
}
.feature-icon {
width: 70px;
height: 70px;
margin: 0 auto 1.5rem;
background: linear-gradient(135deg, #32B0A5, #6A4C93);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 28px;
}
.feature-title {
font-size: 1.4rem;
font-weight: 600;
color: #333;
margin-bottom: 1rem;
}
.feature-description {
color: #666;
font-size: 1rem;
line-height: 1.6;
}
.installation-steps {
background: #f8f9fa;
border-radius: 20px;
padding: 4rem 2rem;
margin-top: 3rem;
}
.steps-title {
font-size: 2.5rem;
color: #6A4C93;
text-align: center;
margin-bottom: 3rem;
font-weight: 600;
}
.steps-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 2.5rem;
margin-bottom: 3rem;
}
.step-card {
background: white;
border-radius: 16px;
padding: 2.5rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
transition: transform 0.3s ease;
}
.step-card:hover {
transform: translateY(-5px);
}
.step-number {
width: 50px;
height: 50px;
background: linear-gradient(135deg, #32B0A5, #6A4C93);
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 1.2rem;
margin-bottom: 1.5rem;
}
.step-title {
font-size: 1.3rem;
font-weight: 600;
color: #333;
margin-bottom: 1rem;
}
.step-description {
color: #666;
line-height: 1.6;
margin-bottom: 1rem;
}
.platform-instructions {
margin-top: 1rem;
}
.platform-title {
font-weight: 600;
color: #32B0A5;
margin-bottom: 0.5rem;
font-size: 1rem;
}
.platform-desc {
color: #777;
font-size: 0.9rem;
margin-bottom: 1rem;
line-height: 1.5;
}
.go-to-app-btn {
background: linear-gradient(135deg, #32B0A5, #6A4C93);
color: white;
padding: 1rem 2rem;
border-radius: 50px;
text-decoration: none;
display: inline-block;
font-weight: 600;
text-align: center;
margin: 2rem auto 0;
transition: all 0.3s ease;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
}
.go-to-app-btn:hover {
transform: translateY(-2px);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2);
}
.btn-container {
text-align: center;
}
.footer {
background: linear-gradient(135deg, #6A4C93, #32B0A5);
color: white;
text-align: center;
padding: 2rem;
}
/* Tablet and mobile responsiveness */
@media (max-width: 1024px) {
.hero-container {
grid-template-columns: 1fr;
gap: 3rem;
text-align: center;
}
.hero-content {
text-align: center;
}
.mockup-container {
order: -1;
}
.mockup-image {
max-width: 450px;
/* Slightly larger on tablets */
}
}
@media (max-width: 768px) {
.hero-section {
padding: 1rem;
min-height: auto;
padding-top: 2rem;
padding-bottom: 3rem;
}
.mockup-image {
max-width: 350px;
/* Appropriate size for mobile */
}
.about-section {
padding: 4rem 1rem;
}
.installation-steps {
padding: 3rem 1rem;
margin: 2rem 1rem 0;
border-radius: 16px;
}
.steps-grid {
grid-template-columns: 1fr;
}
.section-title {
font-size: 2.2rem;
}
.steps-title {
font-size: 2rem;
}
}
@media (max-width: 480px) {
.mockup-image {
max-width: 280px;
/* Smaller for very small screens */
}
}
</style>
</head>
<body>
<!-- Hero Section -->
<section class="hero-section">
<div class="hero-container">
<div class="hero-content">
<h1 class="app-title">وصال</h1>
<p class="app-subtitle">Where your colleagues become your closest friends</p>
<a href="#about" class="download-btn">
<svg class="download-icon" fill="currentColor" viewBox="0 0 20 20">
<path
d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" />
</svg>
Get Wesal App
</a>
</div>
<div class="mockup-container">
<img src="mockup.png" alt="Wesal App Mockup" class="mockup-image">
</div>
</div>
<div class="scroll-indicator">
<svg width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
<path d="M7.41 8.84L12 13.42l4.59-4.58L18 10.25l-6 6-6-6z" />
</svg>
</div>
</section>
<!-- About Section -->
<section id="about" class="about-section">
<h2 class="section-title">What is it?</h2>
<div class="about-description">
<p>A social networking mobile application designed for connecting workplace colleagues. Wesal (Arabic for
"connection") enhances communication and collaboration among division members.</p>
</div>
<div class="features-grid">
<div class="feature-card">
<div class="feature-icon">📱</div>
<h3 class="feature-title">Social Feed</h3>
<p class="feature-description">Create, view, like, and comment on posts to stay connected with your team
</p>
</div>
<div class="feature-card">
<div class="feature-icon">🎉</div>
<h3 class="feature-title">Invitations</h3>
<p class="feature-description">Create and manage event invitations with RSVP functionality for team
gatherings</p>
</div>
<div class="feature-card">
<div class="feature-icon">🧩</div>
<h3 class="feature-title">Daily Puzzles</h3>
<p class="feature-description">Timed daily challenges with leaderboards (9AM-11AM) to engage and compete
with colleagues</p>
</div>
</div>
<div class="installation-steps">
<h3 class="steps-title">How to Install Wesal</h3>
<div class="steps-grid">
<div class="step-card">
<div class="step-number">1</div>
<h4 class="step-title">Visit the App Website</h4>
<p class="step-description">Open your mobile browser and go to <a href="https://web.wesal.online"
style="color: #32B0A5; text-decoration: none; font-weight: 600;">web.wesal.online</a></p>
<div class="platform-instructions">
<div class="platform-title">📱 Both iOS & Android:</div>
<div class="platform-desc">Use Safari, Chrome, or Firefox on your mobile device</div>
</div>
</div>
<div class="step-card">
<div class="step-number">2</div>
<h4 class="step-title">Find the Install Option</h4>
<p class="step-description">Look for the installation prompt in your browser</p>
<div class="platform-instructions">
<div class="platform-title">🍎 iOS (Safari):</div>
<div class="platform-desc">Tap the Share button at the bottom of the screen</div>
<div class="platform-title">🤖 Android (Chrome):</div>
<div class="platform-desc">Tap the three dots menu (⋮) at the top right corner</div>
</div>
</div>
<div class="step-card">
<div class="step-number">3</div>
<h4 class="step-title">Add to Home Screen</h4>
<p class="step-description">Select the installation option from the menu</p>
<div class="platform-instructions">
<div class="platform-title">🍎 iOS:</div>
<div class="platform-desc">Scroll down and tap "Add to Home Screen"</div>
<div class="platform-title">🤖 Android:</div>
<div class="platform-desc">Tap "Add to Home Screen" or "Install App"</div>
</div>
</div>
<div class="step-card">
<div class="step-number">4</div>
<h4 class="step-title">Launch & Enjoy</h4>
<p class="step-description">Confirm the installation and start connecting with colleagues!</p>
<div class="platform-instructions">
<div class="platform-title">📱 Both Platforms:</div>
<div class="platform-desc">Tap "Add" or "Install" to complete the process. Find the Wesal icon
on your home screen and start building workplace friendships!</div>
</div>
</div>
</div>
<div class="btn-container">
<a href="https://web.wesal.online" class="go-to-app-btn">Go to App Now</a>
</div>
</div>
</section>
<!-- Footer -->
<footer class="footer">
<p>&copy; 2025 Wesal App. Connecting colleagues, transforming workplaces.</p>
</footer>
<script>
// Smooth scrolling for anchor links
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
anchor.addEventListener('click', function (e) {
e.preventDefault();
const target = document.querySelector(this.getAttribute('href'));
if (target) {
target.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
});
});
// Show install prompt when available
let deferredPrompt;
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
// Show custom install button or update existing one
const installBtn = document.querySelector('.download-btn');
installBtn.style.display = 'inline-flex';
installBtn.addEventListener('click', async (e) => {
e.preventDefault();
if (deferredPrompt) {
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
console.log(`User ${outcome} the install prompt`);
deferredPrompt = null;
} else {
// If no install prompt available, scroll to instructions
document.querySelector('#about').scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
});
});
// Handle successful installation
window.addEventListener('appinstalled', (evt) => {
console.log('Wesal PWA was installed');
// Hide install button or show success message
const installBtn = document.querySelector('.download-btn');
installBtn.textContent = 'App Installed! ✓';
installBtn.style.background = 'rgba(76, 175, 80, 0.8)';
});
</script>
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 720 KiB