Compare commits

...

10 Commits

Author SHA1 Message Date
sBubshait
999947f559 feat: update docs to add more common issues and landing page
Some checks failed
Deploy to Production / Deploy to Production (push) Has been cancelled
2025-08-07 12:19:39 +03:00
sBubshait
f5f285a1ba feat: notification service update 2025-08-07 12:19:25 +03:00
sBubshait
b02a3bb22e feat: add landing page 2025-08-07 12:19:09 +03:00
sBubshait
bad06d34f4 feat: iOS from receiving triple notificaitons to double only 2025-08-07 11:53:17 +03:00
sBubshait
75e12db502 feat: update timezone of server to be Riyadh UTC+3 2025-08-07 11:29:49 +03:00
sBubshait
f4abfec73b feat: temporarily allow the game to be open until 8pm 2025-08-07 11:16:33 +03:00
sBubshait
5e5c6c7346 fix: unintended changes in the leaderboard JSON model 2025-08-07 11:15:33 +03:00
sBubshait
8541eb4a6e fix: missing import 2025-08-07 10:51:26 +03:00
sBubshait
d8b6f1117b feat: add updated README 2025-08-07 09:54:25 +03:00
sBubshait
e4781c0aa2 feat: lock the puzzles page outside of 9AM-11AM 2025-08-07 09:51:21 +03:00
12 changed files with 1081 additions and 245 deletions

View File

@ -1,2 +1,58 @@
# Wesal
A social app to connect with your colleagues within the Computer Operations Department (COD)
# Wesal App
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,11 +4,14 @@ import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
import java.util.TimeZone;
@SpringBootApplication
@EnableAsync
public class WesalApplication {
public static void main(String[] args) {
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Riyadh"));
SpringApplication.run(WesalApplication.class, args);
}

View File

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

View File

@ -172,15 +172,4 @@ public class PuzzleService {
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,6 +194,15 @@ The database schema is shown below.
\captionof{figure}{Database Schema}
\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
\section{Installation and Setup}
\subsection{Source Code}
@ -540,6 +549,19 @@ 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.
\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}
\begin{itemize}[leftmargin=*]
\item Use database indexing for frequently queried fields

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'dart:async';
import 'wordle_page.dart';
import '../../services/puzzle_service.dart';
import '../../services/user_service.dart';
@ -16,6 +17,8 @@ class _PuzzlesPageState extends State<PuzzlesPage> {
DailyChallenge? _dailyChallenge;
bool _isLoading = false;
String? _errorMessage;
Timer? _timer;
DateTime _currentTime = DateTime.now();
void _showLeaderboardView() {
setState(() {
@ -33,6 +36,51 @@ class _PuzzlesPageState extends State<PuzzlesPage> {
void initState() {
super.initState();
_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 {
@ -56,6 +104,11 @@ class _PuzzlesPageState extends State<PuzzlesPage> {
}
void _startGame() async {
if (!_isGameAvailable) {
_showGameNotAvailableDialog();
return;
}
if (_dailyChallenge == null) {
_loadDailyChallenge();
return;
@ -75,6 +128,35 @@ 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
Widget build(BuildContext context) {
if (_currentView == 'leaderboard') {
@ -122,50 +204,83 @@ class _PuzzlesPageState extends State<PuzzlesPage> {
),
SizedBox(height: 16),
// Challenge timing info
// Challenge timing info with live status
Container(
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
color: (_isGameAvailable ? Colors.green : Colors.orange)
.withOpacity(0.3),
borderRadius: BorderRadius.circular(20),
),
child: Text(
'Daily Wordle Challenge Open Until 11 AM',
style: TextStyle(
fontSize: 16,
color: Colors.white,
fontWeight: FontWeight.w500,
border: Border.all(
color: _isGameAvailable
? Colors.green.shade300
: Colors.orange.shade300,
width: 1,
),
),
child: Column(
children: [
Text(
'Daily Challenge: 09:00 - 11:00',
style: TextStyle(
fontSize: 16,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 4),
Text(
_gameStatusText,
style: TextStyle(
fontSize: 14,
color: Colors.white,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
],
),
),
SizedBox(height: 60),
// Big gamified PLAY button
// Big gamified PLAY button with conditional availability
GestureDetector(
onTap: _isLoading ? null : _startGame,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 40, vertical: 20),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [Colors.white, Colors.white.withOpacity(0.9)],
colors: _isGameAvailable
? [Colors.white, Colors.white.withOpacity(0.9)]
: [Colors.grey[300]!, Colors.grey[400]!],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(25),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.3),
blurRadius: 20,
offset: Offset(0, 8),
),
BoxShadow(
color: Colors.white.withOpacity(0.5),
blurRadius: 10,
offset: Offset(-5, -5),
),
],
boxShadow: _isGameAvailable
? [
BoxShadow(
color: Colors.black.withOpacity(0.3),
blurRadius: 20,
offset: Offset(0, 8),
),
BoxShadow(
color: Colors.white.withOpacity(0.5),
blurRadius: 10,
offset: Offset(-5, -5),
),
]
: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 5,
offset: Offset(0, 2),
),
],
border: Border.all(
color: Color(0xFF6A4C93).withOpacity(0.2),
color: _isGameAvailable
? Color(0xFF6A4C93).withOpacity(0.2)
: Colors.grey.withOpacity(0.3),
width: 2,
),
),
@ -173,9 +288,13 @@ class _PuzzlesPageState extends State<PuzzlesPage> {
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.sports_esports,
_isGameAvailable
? Icons.sports_esports
: Icons.access_time,
size: 32,
color: Color(0xFF6A4C93),
color: _isGameAvailable
? Color(0xFF6A4C93)
: Colors.grey[600],
),
SizedBox(width: 12),
_isLoading
@ -185,32 +304,42 @@ class _PuzzlesPageState extends State<PuzzlesPage> {
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
Color(0xFF6A4C93),
_isGameAvailable
? Color(0xFF6A4C93)
: Colors.grey[600]!,
),
),
)
: Text(
_dailyChallenge?.attempted == true
!_isGameAvailable
? 'GAME CLOSED'
: _dailyChallenge?.attempted == true
? 'VIEW RESULTS'
: 'PLAY NOW',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Color(0xFF6A4C93),
color: _isGameAvailable
? Color(0xFF6A4C93)
: Colors.grey[600],
letterSpacing: 1.5,
),
),
SizedBox(width: 8),
Icon(
Icons.arrow_forward_ios,
_isGameAvailable
? Icons.arrow_forward_ios
: Icons.lock,
size: 20,
color: Color(0xFF6A4C93),
color: _isGameAvailable
? Color(0xFF6A4C93)
: Colors.grey[600],
),
],
),
),
),
// Show error message if any
if (_errorMessage != null) ...[
SizedBox(height: 20),
@ -230,14 +359,18 @@ class _PuzzlesPageState extends State<PuzzlesPage> {
),
),
],
// Show completion status if attempted
if (_dailyChallenge?.attempted == true) ...[
SizedBox(height: 20),
Container(
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12),
decoration: BoxDecoration(
color: (_dailyChallenge!.solved ? Colors.green : Colors.orange).withOpacity(0.2),
color:
(_dailyChallenge!.solved
? Colors.green
: Colors.orange)
.withOpacity(0.2),
borderRadius: BorderRadius.circular(20),
),
child: Text(
@ -253,7 +386,28 @@ 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),
// Leaderboard button
@ -374,11 +528,12 @@ class _LeaderboardContentState extends State<_LeaderboardContent> {
if (userResponse['success'] == true && userResponse['data'] != null) {
_currentUsername = userResponse['data']['username'];
}
if (leaderboardResponse.status) {
_leaderboardData = leaderboardResponse.data;
} else {
_errorMessage = leaderboardResponse.message ?? 'Failed to load leaderboard';
_errorMessage =
leaderboardResponse.message ?? 'Failed to load leaderboard';
}
});
} catch (e) {
@ -402,10 +557,7 @@ class _LeaderboardContentState extends State<_LeaderboardContent> {
SizedBox(height: 16),
Text(
'Loading leaderboard...',
style: TextStyle(
fontSize: 16,
color: Colors.grey[600],
),
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
),
],
),
@ -417,18 +569,11 @@ class _LeaderboardContentState extends State<_LeaderboardContent> {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: Colors.grey[400],
),
Icon(Icons.error_outline, size: 64, color: Colors.grey[400]),
SizedBox(height: 16),
Text(
_errorMessage!,
style: TextStyle(
fontSize: 16,
color: Colors.grey[600],
),
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
textAlign: TextAlign.center,
),
SizedBox(height: 16),
@ -467,10 +612,7 @@ class _LeaderboardContentState extends State<_LeaderboardContent> {
SizedBox(height: 8),
Text(
'Be the first one!',
style: TextStyle(
fontSize: 16,
color: Colors.grey[500],
),
style: TextStyle(fontSize: 16, color: Colors.grey[500]),
),
],
),
@ -516,7 +658,9 @@ class _LeaderboardContentState extends State<_LeaderboardContent> {
margin: EdgeInsets.only(bottom: 12),
padding: EdgeInsets.all(16),
decoration: BoxDecoration(
color: isCurrentUser ? Color(0xFF6A4C93).withOpacity(0.05) : Colors.white,
color: isCurrentUser
? Color(0xFF6A4C93).withOpacity(0.05)
: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
@ -528,8 +672,8 @@ class _LeaderboardContentState extends State<_LeaderboardContent> {
border: isCurrentUser
? Border.all(color: Color(0xFF6A4C93), width: 2)
: rank <= 3
? Border.all(color: rankColor.withOpacity(0.3), width: 2)
: Border.all(color: Colors.grey[200]!, width: 1),
? Border.all(color: rankColor.withOpacity(0.3), width: 2)
: Border.all(color: Colors.grey[200]!, width: 1),
),
child: Row(
children: [
@ -558,12 +702,13 @@ class _LeaderboardContentState extends State<_LeaderboardContent> {
CircleAvatar(
radius: 25,
backgroundColor: Color(0xFF6A4C93).withOpacity(0.1),
backgroundImage: entry.avatar != null && entry.avatar!.isNotEmpty
backgroundImage:
entry.avatar != null && entry.avatar!.isNotEmpty
? NetworkImage(entry.avatar!)
: null,
child: entry.avatar == null || entry.avatar!.isEmpty
? Text(
entry.displayName.isNotEmpty
entry.displayName.isNotEmpty
? entry.displayName[0].toUpperCase()
: '?',
style: TextStyle(
@ -594,7 +739,10 @@ class _LeaderboardContentState extends State<_LeaderboardContent> {
if (isCurrentUser) ...[
SizedBox(width: 8),
Container(
padding: EdgeInsets.symmetric(horizontal: 6, vertical: 2),
padding: EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: Color(0xFF6A4C93),
borderRadius: BorderRadius.circular(8),

View File

@ -383,142 +383,164 @@ class _WordlePageState extends State<WordlePage> with TickerProviderStateMixin {
child: Container(height: 1, color: Colors.grey[200]),
),
),
body: Column(
children: [
Expanded(
child: Center(
child: Container(
constraints: BoxConstraints(maxWidth: 350),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Game Grid
Container(
padding: EdgeInsets.all(16),
child: Column(
children: List.generate(MAX_ATTEMPTS, (row) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 2),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(WORD_LENGTH, (col) {
bool shouldAnimate =
row == animatingRow &&
gridStates[row][col] !=
LetterState.empty;
body: SafeArea(
child: LayoutBuilder(
builder: (context, constraints) {
double screenHeight = constraints.maxHeight;
double keyboardHeight = (gameWon || gameLost) ? 200 : 200; // Estimated heights
double availableHeight = screenHeight - keyboardHeight;
return Column(
children: [
// Game Grid Section
Expanded(
child: Container(
height: availableHeight,
child: Center(
child: Container(
constraints: BoxConstraints(maxWidth: 350),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Game Grid
Container(
padding: EdgeInsets.all(16),
child: Column(
children: List.generate(MAX_ATTEMPTS, (row) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 2),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(WORD_LENGTH, (col) {
bool shouldAnimate =
row == animatingRow &&
gridStates[row][col] !=
LetterState.empty;
return Container(
margin: EdgeInsets.all(2),
width: 62,
height: 62,
child: shouldAnimate
? AnimatedBuilder(
animation: _tileAnimations[col],
builder: (context, child) {
double scale =
0.8 +
(0.2 *
_tileAnimations[col]
.value);
return Transform.scale(
scale: scale,
child: Container(
width: 62,
height: 62,
decoration: BoxDecoration(
border: Border.all(
color:
_getBorderColor(
return Container(
margin: EdgeInsets.all(2),
width: 62,
height: 62,
child: shouldAnimate
? AnimatedBuilder(
animation: _tileAnimations[col],
builder: (context, child) {
double scale =
0.8 +
(0.2 *
_tileAnimations[col]
.value);
return Transform.scale(
scale: scale,
child: Container(
width: 62,
height: 62,
decoration: BoxDecoration(
border: Border.all(
color:
_getBorderColor(
row,
col,
),
width: 2,
),
color:
_getAnimatedTileColor(
row,
col,
_tileAnimations[col]
.value,
),
borderRadius:
BorderRadius.circular(
4,
),
),
child: Center(
child: Text(
grid[row][col],
style: TextStyle(
fontSize: 32,
fontWeight:
FontWeight.bold,
color: _getAnimatedTextColor(
row,
col,
_tileAnimations[col]
.value,
),
),
),
),
),
);
},
)
: Container(
width: 62,
height: 62,
decoration: BoxDecoration(
border: Border.all(
color: _getBorderColor(
row,
col,
),
width: 2,
),
color: _getTileColor(
row,
col,
),
borderRadius:
BorderRadius.circular(4),
),
child: Center(
child: Text(
grid[row][col],
style: TextStyle(
fontSize: 32,
fontWeight:
FontWeight.bold,
color: _getTextColor(
row,
col,
),
width: 2,
),
color:
_getAnimatedTileColor(
row,
col,
_tileAnimations[col]
.value,
),
borderRadius:
BorderRadius.circular(
4,
),
),
child: Center(
child: Text(
grid[row][col],
style: TextStyle(
fontSize: 32,
fontWeight:
FontWeight.bold,
color: _getAnimatedTextColor(
row,
col,
_tileAnimations[col]
.value,
),
),
),
),
),
);
},
)
: Container(
width: 62,
height: 62,
decoration: BoxDecoration(
border: Border.all(
color: _getBorderColor(
row,
col,
),
width: 2,
),
color: _getTileColor(
row,
col,
),
borderRadius:
BorderRadius.circular(4),
),
child: Center(
child: Text(
grid[row][col],
style: TextStyle(
fontSize: 32,
fontWeight:
FontWeight.bold,
color: _getTextColor(
row,
col,
),
),
),
),
),
);
}),
);
}),
),
);
}),
),
),
);
}),
],
),
),
),
],
),
),
),
),
),
// Keyboard or completion message
Container(
padding: EdgeInsets.all(8),
child: (gameWon || gameLost)
? _buildCompletionMessage()
: _buildKeyboard(),
),
],
// Spacer between grid and keyboard
SizedBox(height: 16),
// Keyboard or completion message section
Container(
constraints: BoxConstraints(
minHeight: 200,
maxHeight: 250,
),
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 16),
child: (gameWon || gameLost)
? _buildCompletionMessage()
: _buildKeyboard(),
),
// Bottom padding to ensure keyboard isn't at the very bottom
SizedBox(height: MediaQuery.of(context).padding.bottom + 8),
],
);
},
),
),
),
),

View File

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

View File

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

613
landing_page/index.html Normal file
View File

@ -0,0 +1,613 @@
<!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>

BIN
landing_page/mockup.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 720 KiB