Compare commits
10 Commits
c9188a4e31
...
999947f559
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
999947f559 | ||
|
|
f5f285a1ba | ||
|
|
b02a3bb22e | ||
|
|
bad06d34f4 | ||
|
|
75e12db502 | ||
|
|
f4abfec73b | ||
|
|
5e5c6c7346 | ||
|
|
8541eb4a6e | ||
|
|
d8b6f1117b | ||
|
|
e4781c0aa2 |
60
README.md
60
README.md
@ -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.
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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()));
|
||||
}
|
||||
|
||||
@ -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)";
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
docs/docs.pdf
BIN
docs/docs.pdf
Binary file not shown.
@ -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
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -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
613
landing_page/index.html
Normal 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>© 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
BIN
landing_page/mockup.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 720 KiB |
Loading…
Reference in New Issue
Block a user