Spis treści

1. Projekt konceptualny

1.1 Sformułowanie zadania projektowego

Let'sRun jest serwisem internetowym dla biegaczy, zintegrowanym z aplikacją dla systemu Android, dokonującą pomiaru statystyk biegu. Pomysł na projekt zrodził się z analizy aplikacji dostępnych w Android Market. Obecnie istniejące tego typu aplikacje, są napisane w języku angielskim, co znacznie utrudnia korzystanie użytkownikom. Co więcej, po sprawdzeniu okazało się, że żadna z nich nie oferuje możliwości wysyłania i przechowywania statystyk, dzięki którym biegacze mogliby analizować swoje postępy w treningu. Obecnie nie istnieje żaden serwis społecznościowy, skupiający biegające osoby, który udostępniałby tego typu funkcjonalność.

Jako że Let'sRun jest projektem unikatowym na rynku oraz nastawionym na młode, dbające o swój styl życia osoby, można śmiało przypuszczać że doskonale wypełni lukę istniejącą na rynku i wzbudzi zainteresowanie firm związanych z tematyką sportową.

1.2 Analiza stanu wyjściowego

Obecnie praktycznie nie istnieją produkty które udostępniałyby wszystkie funkcjonalności aplikacji projektu Let'sRun. Istnieją natomiast aplikacje takie jak Run.GPS Trainer Lite czy Running Tracker, które udostępniają zbliżone funkcjonalności. Każda z nich umożliwia odczyt podstawowych parametrów biegu, taki jak: aktualna prędkość, przebiegnięty dystans, czas biegu. Nie istnieje natomiast internetowy serwis społecznościowy, który umożliwiałby przechowywanie wyżej wymienionych statystyk. Let'sRun jest produktem niewątpliwie innowacyjnym. Po pierwsze, udostępnia użytkownikom aplikację do monitorowania parametrów biegu w czasie rzeczywistym i w języku polskim, co jest ewenementem na rynku. Po drugie, jego częścią jest serwis społecznościowy, który umożliwia przechowywanie statystyk zebranych przez aplikację mobilną i monitorowanie postępów w treningu, oraz porównywanie swoich wyników z innymi biegaczami. Obydwa przedstawione rozwiązania celują w grupę młodych, nowoczesnych i dobrze sytuowanych młodych ludzi, dając im produkt który jest przez rynek pożądany.

1.3 Analiza wymagań użytkownika i wstępne określenie funkcjonalności

Serwis internetowy

W części projektu Let’s Run związanej z istnieniem serwisu internetowego, przewiduje się trzy typy użytkowników: administrator, użytkownik niezarejestrowany i zarejestrowany. Administrator będzie posiadał pełne prawa zarządzania serwisem, zarówno od strony merytorycznej jak i technicznej. Będzie to możliwe dzięki dostępowi tak do bazy danych jak i do treści poszczególnych podstron. Aby ułatwić edycję treści pojawiających się na stronie, użytkownik będzie korzystał z systemu zarządzania treścią Drupal w wersji 6.20 (Drupal CMS). Użytkownik niezarejestrowany będzie miał ograniczony dostęp do serwisu, a więc poza stronami informacyjnymi, będzie mógł przeglądać jedynie rankingi ogólne, jak również otrzyma możliwość założenia konta. Użytkownik zarejestrowany będzie mógł przeglądać strony dostępne dla użytkownika niezarejestrowanego, jak również (po zalogowaniu) indywidualne statystyki każdego pojedynczego biegu, a także podsumowania i statystyki dla wybranego okresu czasu.

Aplikacja na urządzenia mobilne

  • Śledzenie i rejestracja parametrów biegu
  • Zapamiętywanie wyników biegu i rankingów
  • Synchronizacja danych z bazą serwisu internetowego
  • Przeglądanie parametrów biegu i rankingów

1.4 Określenie scenariuszy użycia

Serwis internetowy

  1. Administrator
    1. Zarządzanie treścią strony
      1. Dodawanie podstron
      2. Usuwanie podstron
      3. Edycja treści
    2. Zarządzanie statystykami
      1. Przeglądanie statystyk serwisu
  2. Użytkownik niezarejestrowany
    1. Przeglądanie podstron informacyjnych
      1. Podstrona FAQ
      2. Podstrona Let'sRun Android
    2. Przeglądanie rankingów ogólnodostępnych
      1. Wyświetlenie wybranego rankingu
    3. Rejestracja w serwisie
      1. Zaproponowanie loginu
      2. Zaproponowanie hasła
      3. Powtórzenie hasła
      4. Powtórzenie maila
    4. Pobranie aplikacji mobilnej
    5. Przypomnienie hasła
  3. Użytkownik zarejestrowany
    1. Logowanie do serwisu
      1. Podanie loginu
      2. Podanie hasła
    2. Przeglądanie podstron informacyjnych
    3. Przeglądanie rankingów ogólnodostępnych
    4. Przeglądanie statystyk indywidualnych
      1. Pojedynczy bieg
        1. Lista wszystkich biegów
        2. Wybór pojedynczego biegu
        3. Statystyki pojedynczego biegu
      2. Wybrany okres
        1. Lista biegów w wybranym okresie
        2. Statystyki dla wybranego okresu
      3. Miejsca w rankingach
        1. Lista miejsc w rankingach ogólnych
    5. Pobranie aplikacji mobilnej
    6. Wylogowanie

Aplikacja na urządzenia mobilne

  1. Użytkownik uruchamia aplikację
  2. Przejście do Main Menu
    1. Wybrano „Idziemy biegać”
      1. Przejście do ekranu wyboru preferencji biegu
      2. Ustawienia preferencji biegu
      3. Wciśnięto klawisz „Biegnij”
        1. Uruchomienie śledzenia biegu na podanych warunkach
        2. Zakończenie śledzenia biegu, przejdź do podania loginu i hasła
      4. Wciśnięto klawisz „Wróć”, powrót do Main Menu
    2. Wybrano „Logowanie”
      1. Użytkownik podaje login i hasło
      2. Wybrano „Podgląd wyników”
        1. Wyświetlenie listy biegów i informacji rankingowych
        2. Wybrano dowolny bieg
        3. Wciśnięto „Wróć”, powrót do Main Menu
      3. Wybrano „Prześlij wyniki”
        1. Przesłanie do bazy serwisu rekordów, które nie zostały jeszcze zsynchronizowane
        2. Aktualizacja tabeli osiągnięcia
        3. Przejście do wyświetlenia listy biegów
      4. Wciśnięto klawisz „Wróć”, powrót do Main Menu
    3. Wybrano „Wyjście”, zamknięcie aplikacji

1.5 Identyfikacja funkcji

Serwis internetowy

  1. Rejestracja w serwisie
  2. Logowanie
  3. Wylogowanie
  4. Potwierdzenie rejestracji
  5. Przeglądanie podstron informacyjnych
  6. Przeglądanie rankingów ogólnych wg kryteriów
  7. Przeglądanie statystyk indywidualnych wg kryteriów
  8. Przeglądanie statystyk pojedynczego biegu
  9. Przypomnienie hasła
  10. Pobranie aplikacji mobilnej

Aplikacja na urządzenia mobilne

Aplikacja mobilna będzie korzystała z bazy danych typu SQLitle i SDK Android w wersji 1.6 by zapewnić wsteczną kompatybilność. Dla łatwego korzystania z aplikacji zostanie wykonany interfejs użytkownika. Podstawowymi operacjami wykonywanymi na bazie będą select, insert oraz remove. Aplikacja będzie również synchronizować bazę z bazą serwisu internetowego za pomocą funkcji httpresponse(). Wszystkie funkcje obsługiwane przez aplikację będą tworzone w oparciu o SDK Android 1.6.

1.6 Data Flow Diagram dla serwisu internetowego

Diagram kontekstualny

Diagram użytkownika niezarejestrowanego

Diagram użytkownika zarejestrowanego

Diagram administratora

1.7 Entity-Relationship Diagram dla serwisu internetowego

1.8 State Transition Diagram dla serwisu internetowego

STD użytkownika niezarejestrowanego

STD użytkownika zarejestrowanego

}

STD administratora

1.9 Data Flow Diagram dla aplikacji na urządzenia mobilne

DFD kontekstowy

DFD pierwszego rzędu

DFD drugiego rzędu dla procesu drugiego

DFD drugiego rzędu dla procesu trzeciego

1.10 Entity-Relationship Diagram dla aplikacji na urządzenia mobilne

Encje użyte w projekcie

Schemat relacji pomiędzy encjami

1.11 State Transition Diagram dla aplikacji na urządzenia mobilne

Schemat relacji pomiędzy encjami

2. Projekt logiczny

2.1 Projektowanie tabel, kluczy, kluczy obcych, powiązań między tabelami, indeksów, etc. w oparciu o zdefiniowany diagram ERD

Dla serwisu internetowego (identyczne komendy są stosowane również w aplikacji mobilnej)

CREATE TABLE users
	(	userid INT UNSIGNED NOT NULL AUTO_INCREMENT,
		login CHAR(50) NOT NULL,
		password CHAR(50) NOT NULL,
		email CHAR(50) NOT NULL,
		confirmed CHAR(32) NOT NULL,
		distance INT NOT NULL,
		max_distance INT NOT NULL,
		time INT NOT NULL,
		max_time INT NOT NULL,
		kcal INT NOT NULL,
		max_kcal INT NOT NULL,
		avg_speed INT NOT NULL,
		max_speed INT NOT NULL,
		PRIMARY KEY (userid)
	)	ENGINE=INNODB;
CREATE TABLE tracks
	(	trackid INT UNSIGNED NOT NULL AUTO_INCREMENT,
		userid INT UNSIGNED NOT NULL,
		distance INT NOT NULL,
		time INT NOT NULL,
		kcal INT NOT NULL,
		avg_speed FLOAT(6,2) NOT NULL,
		maximum_speed INT NOT NULL,
		date_time DATETIME NOT NULL,
		PRIMARY KEY (trackid),
		FOREIGN KEY (userid) REFERENCES users(userid) ON DELETE CASCADE
	)	ENGINE=INNODB;

Słownik pól bazy danych

Tabela users

  1. userid INT UNSIGNED NOT NULL AUTO_INCREMENT - klucz główny tabeli users
  2. login CHAR(50) NOT NULL - login użytkownika podany w czasie rejestracji
  3. password CHAR(50) NOT NULL - hasło użytkownika podane w czasie rejestracji
  4. email CHAR(50) NOT NULL - email użytkownika podany w czasie rejestracji
  5. confirmed CHAR(32) NOT NULL - pole służące do potwierdzenia autoryzacji konta przez użytkownika
  6. distance INT NOT NULL - suma dystansów wszystkich tras przebiegniętych przez użytkownika
  7. max_distance INT NOT NULL - maksymalna wartość dystansu pośród tras przebiegniętych przez użytkownika
  8. time INT NOT NULL - suma czasów wszystkich tras przebiegniętych przez użytkownika
  9. max_time INT NOT NULL - maksymalna wartość czasu biegu pośród tras przebiegniętych przez użytkownika
  10. kcal INT NOT NULL - suma wszystkich spalonych kalorii w trakcie wszystkich biegów
  11. max_kcal INT NOT NULL - maksymalna wartość spalonych kalorii pośród tras przebiegniętych przez użytkownika
  12. avg_speed INT NOT NULL - średnia prędkość biegacza ze wszystkich tras
  13. max_speed INT NOT NULL - maksymalna wartość prędkości osiągnięta przez biegacza ze wszystkich tras

Tabela tracks

  1. trackid INT UNSIGNED NOT NULL AUTO_INCREMENT - klucz obcy tabeli users służący do powiązania biegu z danym użytkowikiem
  2. userid INT UNSIGNED NOT NULL - klucz główny tabeli tracks
  3. distance INT NOT NULL - dystans uzyskany w danym biegu
  4. time INT NOT NULL - czas biegu na danej trasie
  5. kcal INT NOT NULL - ilość spalonych kalorii na danej trasie
  6. avg_speed FLOAT(6,2) NOT NULL - średnia prędkość biegu na danej trasie
  7. maximum_speed INT NOT NULL - maksymalna wartość biegu na trasie
  8. date_time DATETIME NOT NULL - data odbycia biegu (format RRRR-MM-DD HH:MM:SS)

Znaczenie pól w bazie po stronie aplikacji mobilnej identyczne jak w przypadku aplikacji webowej.

2.2 Analiza zależności funkcyjnych i normalizacja tabel (dekompozycja do 3NF, BCNF, 4NF, 5NF)

Baza danych spełnia 1NF ponieważ każda składowa w każdej kropce jest atomowa i nie da się jej podzielić.

Baza danych spełnia 2NF ponieważ spełnia 1NF oraz każdy element jest zależnie funkcyjny od kluczy poszczególnych tabeli.

Baza danych spełnie 3NF ponieważ spełnia 2NF oraz każdy atrybut jest bezpośrednio zależny od klucza głównego w poszczególnych tabelach.

2.3 Projektowanie operacji na danych

Poniższe zapytania wykorzystują zmienne PHP, przechowujące odpowiednie wartości:

Włożenie do bazy nowej trasy:

INSERT INTO tracks (userid,distance,time,kcal,maximum_speed,avg_speed,date_time) VALUES ('.$userid.','.$distance.','.$time.','.$kcal.','.$maximum_speed.','.$avg_speed.',"'.$date_time.'")

Update tabeli users przechowującej wartości maksymalne dla użytkownika - niemożliwe było zastosowanie triggera z racji ograniczeń nałożonych na wykorzystywany hosting:

UPDATE users SET distance='.$udistance.', max_distance='.$umax_distance.', time='.$utime.', max_time='.$umax_time.', kcal='.$ukcal.', max_kcal='.$umax_kcal.', avg_speed='.$uavg_speed.', max_speed='.$umax_speed.' WHERE userid='.$userid

Dodanie nowego użytkownika (wartość md5 pozwala na identyfikację potwierdzonego użytkownika):

INSERT INTO users (login,password,email,confirmed,distance,max_distance,time,max_time,kcal,max_kcal,avg_speed,max_speed) VALUES ("'.$login.'","'.$password.'","'.$email.'","'.$md5.'",0,0,0,0,0,0,0,0)

Autoryzacja (potwierdzenie użytkownika):

UPDATE users SET confirmed="1" WHERE confirmed="'.$confirm.'"

Zapytania wykorzystywane do tworzenia rankingów (w zależności od typu rankingu oprócz loginu, pobierana była odpowiednia wartość a wiec distance, max_distance itd.):

SELECT login,distance FROM users ORDER BY distance DESC

Statystyki pojedynczego biegu dla zalogowanego użytkownika w oparciu o zmienną sesji:

SELECT date_time, distance, time, maximum_speed, kcal FROM tracks WHERE userid='.$_SESSION['luserid'].' AND trackid="'.$trackid.'"

Wybór tras dla interwałów czasowych, kolejno: ostatni tydzień, dwa ostatnie tygodnie, ostatni miesiąc, przedział czasowy wprowadzony przez użytkownika:

SELECT date_time, trackid FROM tracks WHERE userid='.$_SESSION['luserid'].' AND date_time BETWEEN DATE_SUB(NOW(),INTERVAL 7 DAY) AND NOW()
SELECT date_time, trackid FROM tracks WHERE userid='.$_SESSION['luserid'].' AND date_time BETWEEN DATE_SUB(NOW(),INTERVAL 14 DAY) AND NOW()
SELECT date_time, trackid FROM tracks WHERE userid='.$_SESSION['luserid'].' AND date_time BETWEEN DATE_SUB(NOW(),INTERVAL 30 DAY) AND NOW()
SELECT date_time, trackid FROM tracks WHERE userid='.$_SESSION['luserid'].' AND date_time BETWEEN "'.$start.'" AND "'.$end.'"

oraz stworzenie statystyk dla wybranego okresu:

SELECT max(distance),avg(distance),max(time),avg(time),max(maximum_speed),sum(distance),sum(time) FROM tracks WHERE userid='.$_SESSION['luserid'].' AND date_time BETWEEN DATE_SUB(NOW(),INTERVAL 7 DAY) AND NOW()
SELECT max(distance),avg(distance),max(time),avg(time),max(maximum_speed),sum(distance),sum(time) FROM tracks WHERE userid='.$_SESSION['luserid'].' AND date_time BETWEEN DATE_SUB(NOW(),INTERVAL 14 DAY) AND NOW()
SELECT max(distance),avg(distance),max(time),avg(time),max(maximum_speed),sum(distance),sum(time) FROM tracks WHERE userid='.$_SESSION['luserid'].' AND date_time BETWEEN DATE_SUB(NOW(),INTERVAL 30 DAY) AND NOW()
SELECT max(distance),avg(distance),max(time),avg(time),max(maximum_speed),sum(distance),sum(time) FROM tracks WHERE userid='.$_SESSION['luserid'].' AND date_time BETWEEN "'.$start.'" AND "'.$end.'"

2.4 Klient Android

Dla gromadzenia informacji o biegach użytkowników serwisu Let's Run, posłuży nam aplikacja na telefony posiadające system operacyjny Android w wersji od 1.6 w wzwyż (każda wyższa wersja zapewnia wsparcie dla wersji poprzednich, dzieje się tak dzięki polityce producenta systemu firmy Google). Wybór tej wersji Android SDK, został podyktowany tym iż jest ono zamkniętym projektem i w pełni funkcjonalny.

Nasz produkt jest aplikacją przeznaczoną do śledzenia statystyk biegów, które następnie zapisywane są w bazie SQLite aplikacji. Na żądanie użytkownika, po podaniu logina i hasła, wszystkie biegi są wysyłane na serwer aplikacji webowej. W tym momencie dane zapisywane są w bazie i przeliczane są nowe statystyki użytkownika, po zakończeniu tej operacji statystyki zostają wysłane na urządzenie mobilne.

W celu spełnienia tych zadań zostały stworzone następujące mechanizmy:

Tworzenie bazy danych aplikacji

private static final String DATABASE_NAME = "letsrun.db";
	private static final int DATABASE_VERSION = 40;
	public static final String TABLE_USERS = "users";
	public static final String TABLE_TRACKS = "tracks";

	public MyOpenHelper(Context context) {
		super(context, DATABASE_NAME, null, DATABASE_VERSION);
	}

	@Override
	public void onCreate(SQLiteDatabase db) {
		db.execSQL("CREATE TABLE " + TABLE_USERS + 
				" (userid INTEGER PRIMARY KEY AUTOINCREMENT," + 
				"login CHAR(50) NOT NULL," +
				"password CHAR(50) NOT NULL," +
				"email CHAR(50) NOT NULL,"+
				"confirmed CHAR(32) ," +
				"distance INTEGER DEFAULT 0," +
				"max_distance INTEGER DEFAULT 0," +
				"time INTEGER DEFAULT 0," +
				"max_time INTEGER DEFAULT 0," +
				"kcal INTEGER DEFAULT 0," +
				"max_kcal INTEGER DEFAULT 0," +
				"avg_speed INTEGER DEFAULT 0," +
				"max_speed INTEGER DEFAULT 0" +
				");");

		db.execSQL("CREATE TABLE " + TABLE_TRACKS + 
			" (_id INTEGER PRIMARY KEY AUTOINCREMENT," + 
			"userid INTEGER NOT NULL, " + 
			"distance INTEGER NOT NULL," + 
			"time INTEGER NOT NULL," +
			"kcal INTEGER NOT NULL," + 
			"avg_speed INTEGER NOT NULL," +
			"maximum_speed INTEGER NOT NULL," +
			"date_time TEXT NOT NULL," +
			"synchronized INTEGER NOT NULL DEFAULT 0," +				
			"FOREIGN KEY (userid) REFERENCES users(userid)" + 
			");");	
		
		ContentValues values = new ContentValues();
		values.put("login", "user");
		values.put("password", "");
		values.put("email", "");
		
		db.insert(TABLE_USERS, null, values);
		Cursor s = db.query("users", new String[]{"*"}, null, null, null, null, null);	
	}
	
	@Override
	public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
		db.execSQL("DROP TABLE IF EXISTS " + TABLE_USERS);
		db.execSQL("DROP TABLE IF EXISTS " + TABLE_TRACKS);
		onCreate(db);
	}	

Obsługa zapytań do bazy

private Context context;
	private SQLiteDatabase database;
	private MyOpenHelper dbHelper;

	public DatabaseAdapter (Context context) {
		this.context = context;
	}
	
	public DatabaseAdapter open() throws SQLException {
		try{
		dbHelper = new MyOpenHelper(context);
		database = dbHelper.getWritableDatabase();
		} catch (SQLException s) {
			Log.d("", "XXX" + s.toString());
		}
		return this;
	}
	
	public void close() {
		dbHelper.close();
	}
	
	public void AddToUsers (String login, String password, String email,
								   String confirmed, int distance, int maxdist, int time, int maxtime,
								   int kcal, int maxkcal, int avgspeed, int maxspeed) {		
		
		ContentValues values = new ContentValues();
		
		
		values.put("login", login);
		values.put("password", password);
		values.put("email", email);
		values.put("confirmed", confirmed);
		values.put("distance", distance);
		values.put("max_distance", maxdist);
		values.put("time", time);
		values.put("max_time", maxtime);
		values.put("kcal", kcal);
		values.put("max_kcal", maxkcal);
		values.put("avg_speed", avgspeed);
		values.put("max_speed", maxspeed);
			
		database.insertOrThrow(MyOpenHelper.TABLE_USERS, null, values);		
	}
	
	public void AddToTracks (int userid, int distance, int time, int kcal, int avgspeed,
									int maxspeed, String datetime, int synchro) {		
		
		ContentValues values = new ContentValues();
		
		values.put("userid", userid);
		values.put("distance", distance);
		values.put("time", time);
		values.put("kcal", kcal);
		values.put("avg_speed", avgspeed);
		values.put("maximum_speed", maxspeed);
		values.put("date_time", datetime);
		values.put("synchronized", synchro);
		
		database.insertOrThrow(MyOpenHelper.TABLE_TRACKS, null, values);		
	}
	
	public Cursor getTrack(int id) {
		String sid = Integer.toString(id);
		Cursor cursor = database.query(MyOpenHelper.TABLE_TRACKS, new String[] {"_id","date_time","distance","time", "kcal", "avg_speed", "maximum_speed"},
				"_id = "+ sid, null, null, null, null);
		if (cursor != null) {
			cursor.moveToFirst();
		}
		return cursor;
	}
	
	public Cursor getUnsynchronizedTracks() {
		Cursor cursor = database.query(MyOpenHelper.TABLE_TRACKS, new String [] {"*"}, "synchronized = 0", null, null, null, null);
		if (cursor != null) {
			cursor.moveToFirst();
		}
		return cursor;
	}
	
	public void setSynchronizedTrack(int id) {
		ContentValues cv = new ContentValues();
		cv.put("synchronized", 1);
		database.update(MyOpenHelper.TABLE_TRACKS, cv, "synchronized = 0", null);		
	}
	
	public void updateUserStatistics(int dist, int max_dist, int time, int max_time, int kcal, int max_speed, int avg_speed) {

		ContentValues cv = new ContentValues();
		cv.put("distance", dist);
		cv.put("max_distance", max_dist);
		cv.put("time", time);
		cv.put("max_time", max_time);
		cv.put("kcal", kcal);
		cv.put("max_speed", max_speed);
		cv.put("avg_speed", avg_speed);
		database.update(MyOpenHelper.TABLE_USERS, cv, null, null);
	}
	
	public Cursor getStats() {
		Cursor cursor = database.query(MyOpenHelper.TABLE_USERS, new String[] {"*"}, null, null, null, null, null);
		if (cursor != null) {
			cursor.moveToFirst();
		}		
		return cursor;
	}
	
	public void addFakeDataToTracks() {
		AddToTracks(1, 100, 2, 3, 5, 3, "2011-03-23 08:23:22", 0);
		AddToTracks(1, 240, 2, 3, 5, 3, "2011-04-13 08:34:06", 0);
	}
	
	public String getCurrentUser() {
		
		Cursor c = database.query(MyOpenHelper.TABLE_USERS, new String[] {"login"}, null, null, null, null, null);
		c.moveToFirst();
		return c.getString(c.getColumnIndex("login"));
	}
	
	public void updateUser(String login) {
		ContentValues values = new ContentValues();
		values.put("login", login);
		database.update(MyOpenHelper.TABLE_USERS, values, null, null);
	}

	public SQLiteDatabase getDatabase() {
		return database;
	}

Obsługa wysyłania tras na serwer webowy

 public void sendData()
	 {
		 flag=0;
		 String result = null;
			StringBuilder sb = null;
		String login = etLogin.getText().toString();
		String password = etPassword.getText().toString();
		if(login.length()!=0 && password.length()!=0)
		{
		dbHelper.open();
		Cursor uTracks = dbHelper.getUnsynchronizedTracks();
		
		for(int i=0; i < uTracks.getCount(); i++) {
			int id = uTracks.getInt(uTracks.getColumnIndex("_id"));
			String distance = uTracks.getString((uTracks.getColumnIndex("distance")));
			String time = uTracks.getString((uTracks.getColumnIndex("time")));
			String kcal = uTracks.getString((uTracks.getColumnIndex("kcal")));
			String maximum_speed = uTracks.getString((uTracks.getColumnIndex("maximum_speed")));
			String date_time = uTracks.getString((uTracks.getColumnIndex("date_time")));
			
			nameValuePairs.add(new BasicNameValuePair("login", login));
			nameValuePairs.add(new BasicNameValuePair("password", password));
			nameValuePairs.add(new BasicNameValuePair("distance", distance));
			nameValuePairs.add(new BasicNameValuePair("time", time));
			nameValuePairs.add(new BasicNameValuePair("kcal", kcal));
			nameValuePairs.add(new BasicNameValuePair("maximum_speed", maximum_speed));
			nameValuePairs.add(new BasicNameValuePair("date_time", date_time));		
			
			uTracks.moveToNext();
			Toast.makeText(this, "Synchronizacja tras biegu:\n" + i + "/" + uTracks.getCount(), 0).show();
			try{
		        HttpClient httpclient = new DefaultHttpClient();
		        HttpPost httppost = new HttpPost("http://www.letsrun.pl/functions/put_tracks.php");
		        httppost.setEntity(new UrlEncodedFormEntity(nameValuePairs));
		        HttpResponse response = httpclient.execute(httppost);
		        HttpEntity entity = response.getEntity();
		        InputStream is = entity.getContent();
		        //log_err
		        StringBuilder text = inputStreamToString(is);
		        if(text.toString()=="log_err")
		        {
		        	Toast.makeText(this, "Błędny login lub hasło!!!!", 0).show();
			    	flag=1;
		        } else {
		        	dbHelper.setSynchronizedTrack(id);
		        	dbHelper.updateUser(login);
		        }
		    }
		    catch(Exception e)
		    {
		    	Toast.makeText(this, "Brak połączenia z bazą.\n Jeżeli problem powtórzy się prosimy skontaktować się z administratorem!!!",0).show();
		    	flag=1;
		    }
		}
		}
		else
		{
			Toast.makeText(this, "Błędny login lub hasło!!!!", 0).show();
	    	flag=1;
		}
	    
	 }

Obsługa odbioru statystyk przez aplikację mobilną

public void postData() {
	    // Create a new HttpClient and Post Header
		
		JSONArray jArray; 
		InputStream is = null;
		final TextView debbug = (TextView)findViewById(R.id.debbug);
		HttpClient httpclient = new DefaultHttpClient();
	    HttpPost httppost = new HttpPost("http://www.letsrun.pl/functions/get_stats.php");

	    try {
	    	String login = etLogin.getText().toString();
			String password = etPassword.getText().toString();
	        List<NameValuePair> nameValuePairs = new ArrayList<NameValuePair>();
	        nameValuePairs.add(new BasicNameValuePair("login", login));
			nameValuePairs.add(new BasicNameValuePair("password", password));
	        httppost.setEntity(new UrlEncodedFormEntity(nameValuePairs));
	        HttpResponse response = httpclient.execute(httppost);
	        HttpEntity entity = response.getEntity();
	        is = entity.getContent();
	        StringBuilder text = inputStreamToString(is);
	        
	        //json parser
	        
	        JSONObject json_data=null;
	        int distance;
	        int max_distance; 
	        int time;
	        int max_time; 
	        int kcal; 
	        int max_speed; 
	        int avg_speed;

	        try{
	        	jArray = new JSONArray(text.toString());
	        	for(int i =0; i<jArray.length();i++)
	        	{
	        		json_data = jArray.getJSONObject(i);
	        		distance = json_data.getInt("distance");
	        		max_distance = json_data.getInt("max_distance");
	        		time = json_data.getInt("time");
	        		max_time = json_data.getInt("max_time");
	        		kcal = json_data.getInt("kcal");
	        		max_speed = json_data.getInt("max_speed");
	        		avg_speed = json_data.getInt("avg_speed");
	        		
	        		dbHelper.updateUserStatistics(distance, max_distance, time, max_time, kcal, max_speed, avg_speed);
	        		
	        	}
	        }
	        catch(JSONException e1){
	        		            Toast.makeText(getBaseContext(), "Błędny login lub hasło", Toast.LENGTH_LONG).show();
	        		        flag=1;
	        }catch (ParseException e1){
	        		        	 Toast.makeText(getBaseContext(), "Błędny aktualizacji", Toast.LENGTH_LONG).show();
	        		        	 flag=1;
	        }
	        

	    } catch (ClientProtocolException e) {
	    	Toast.makeText(this, "Brak połączenia z bazą.\n Jeżeli problem powtórzy się prosimy skontaktować się z administratorem!!!", 0).show();
	    	flag=1;
	    } catch (IOException e) {
	    	Toast.makeText(this, "Brak połączenia z bazą.\n Jeżeli problem powtórzy się prosimy skontaktować się z administratorem!!!", 0).show();
	    	flag=1;
	    }
	    if(flag==0){
	    Toast.makeText(this, "Synchronizacja wyników zakończona powodzeniem", 0).show();
	    }
	}

3. Raport końcowy

a) Serwis internetowy

3.1 Baza danych

Baza danych serwisu internetowego została zaimplementowana z wykorzystaniem motoru bazy MySQL w wersji 5.1.55. Zapytania tworzące bazę, zostały zaprezentowane w projekcie logicznym.

3.2 Formularze

Formularze wykorzystywane w projekcie, były stosowane w dwóch przypadkach: rejestracja oraz logowanie. Częściowo użyto również pól formularzy, przy wyborze przez użytkownika interwału czasowego dla wyświetlanych tras w połączeniu ze skryptem kalendarza JavaScript (Date Time Picker) z dostosowaniem formatu daty do składni SQL. Walidacja formularzy logowania i rejestracji była wykonana za dwupoziomowe: pomocą biblioteki jquery oraz jquery.validate oraz na poziomie PHP (w przypadku gdyby użytkownik miał wyłączoną obsługę JavaScript). Formularz logowania:

<form action="index.php" id="logForm" method="post">
				<table>
				<tr>
				<td>Login:</td><td><input type="text" name="login" class="required" minlength="5"/></td>
				</tr>
				<tr>
				<td>Hasło:</td><td><input type="password" name="pass" class="required" minlength="5"/></td>
				</tr>
				<tr>
				<td></td>
				<td align="right">
				<input type="submit" value="Zaloguj" class="btn"/>
				</td></tr>
				</table>
</form>

Formularz rejestracji:

<form action="index.php" id="regForm" method="post">
Login:
<input type="text" name="login" minlength="5" size="20"/>
E-mail:
<input type="text" name="email" class="required email" size="20"/>
Hasło:
<input id="password" type="password" name="pass" class="required" minlength="5" size="20"/>
Potwierdź hasło:
<input type="password" name="confirm_password" class="required" minlength="5" size="20"/>
</br>
<input type="submit" value="Zarejestruj" class="btn2"/>
</form>

Wybór interesującego nas okresu:

3.3 Panel sterowania oraz realizacja funkcjonalności

Aby ułatwić zarządzanie tworzeniem i nadzorowaniem części wizualnej serwisu, wykorzystano system zarządzania treścią Drupal w wersji 6.20. Szablon strony wykonano w oparciu o CSS spinając go z systemem CMS za pomocą jego wewnętrznych zmiennych i znaczników.

Pierwszym zadaniem serwisu, była możliwość rejestracji i logowania nowych użytkowników. Rejestracja przebiegała dwuetapowo: po wypełnieniu pól formularza i walidacji z wykorzystaniem zarówno skryptu JavaScript jak i walidatora w PHP, użytkownik był wstawiany do bazy z polem aktywności ustawionym na nieaktywny. Pole to było aktualizowane dopiero po kliknięciu na link, który użytkownik otrzymywał w mailu. Wartość pola określona była przez kod md5. Funkcja realizująca rejestrację:

/**
*	Registers user with given login, email, password and db connection handler and sens confirmation mail.
*	@param string @login User login.
*	@param string @email User email.
*	@param string @password User password.
*	@param handler @dbDb connection handler.
**/
functionregister_user($login,$email,$password,$db)
{
	//Check login
	$result = $db->query('select * from users where login="'.$login.'"');
	if(!$result)
	{
		echo '<p>Zapytanie nie powiodło się!</p>';
		return false;
	}
	if($result->num_rows>0)
	{
		echo '<p>Wybierz inny login!</p>';
		return false;
	}
	
	//Check email
	$result = $db->query('select * from users where email="'.$email.'"');
	if(!$result)
	{
		echo '<p>Zapytanie nie powiodło się!</p>';
		return false;
	}
	if($result->num_rows>0)
	{
		echo '<p>Ten mail jest już w użyciu!</p>';
		return false;
	}
	//Register
	$md5 = md5(time());
	$result = $db->query('INSERT INTO users (login,password,email,confirmed,distance,max_distance,time,max_time,kcal,max_kcal,avg_speed,max_speed) VALUES ("'.$login.'","'.$password.'","'.$email.'","'.$md5.'",0,0,0,0,0,0,0,0)');
	
	if(!$result)
	{
		echo '<p>Rejestracja niemożliwa.</p>';
		returnfalse;
	}
	else
	{
		echo '<p>Użytkownik <b>'.$login.'</b> został poprawnie zarejestrowany. Aby aktywować konto, kliknij na link przesłany na Twoją skrzynkę mailową</p>';
	}
	$mail_content = "Wiadomość została automatycznie wygenerowana, ponieważ zarejestrowałeś się w serwisie Let'sRun.pl \n\nAby aktywować konto, kliknij w poniższy link: \n\nhttp://www.drupal.pisulak.pl/index.php?confirm=".$md5." \n\nJeżeli nie rejestrowałeś się w serwisie Let'sRun.pl zignoruj tą wiadomość, a konto zostanie usunięte w przeciągu 24h.";
	$mailr = mail($email, 'Let\'sRun - potwierdzeniezałożeniakonta', $mail_content,'From: rejestracja@letsrun.pl' . "\n");
	if(!$mailr)
	{
		echo '<p>Nie można wysłać maila z potwierdzeniem!</p>';
		
	}
	
}

Oraz aktywację:

/**
*	Confirms user.
*	@param handler @dbDb connection handler.
**/
functionconfirm_user($db)
{	
	if(!is_null($_GET['confirm']))
	{
		$confirm = $_GET['confirm'];
		$result = $db->query('SELECT confirmed FROM users WHERE confirmed="'.$confirm.'"');
		if(!$result)
		{
			echo '<p>Zapytanie nie powiodło się!</p>';
			return false;
		}
		if($result->num_rows>0)
		{
			$result2 = $db->query('SELECT login FROM users WHERE confirmed="'.$confirm.'"');
			$result = $db->query('UPDATE users SET confirmed="1" WHERE confirmed="'.$confirm.'"');
			if($result)
			{
				$r = $result2->fetch_assoc();
				echo '<p>Użtkownik<b>'.$r['login'].'</b> został potwierdzony.</p>';
			}
		}else
		{
			echo '<p>Użytkownik został już potwierdzony, bądź nie istnieje w bazie.</p>';
			returnfalse;
		}
		
	}
}

Do logowania wykorzystano zmienną sesji:

/**
 * Authenticate user and create session
**/
functionlog_user($login,$password,$db)
{
	$result = $db->query('select login from users where login="'.$login.'"');
	if(!$result)
	{
		return false;
	}
	if($result->num_rows>0)
	{
		$result = $db->query('select password from users where login="'.$login.'"');
		if(!$result)
		{
			return false;
		}
		$table = $result->fetch_assoc();
		if($table['password'] == $password)
		{
			$result = $db->query('select confirmed from users where login="'.$login.'"');
			if(!result)
			{
				return false;
			}
			$table = $result->fetch_assoc();
			if($table['confirmed'] == 1)
			{
				new_session($login);
				return true;
			}
		}
	}
}

Pobieranie danych z bazy zależało w dużym stopniu do typu pobieranych danych. Przykładowa funkcja tworząca ranking dla skumulowanego dystansu:

/**
*	Creates output table with rank of summed up distances.
**/
functionlongest_cumulated_distance()
{
	$db = connect_letsrun();
	$result = $db->query('SELECT login,distance FROM users ORDER BY distance DESC');
	if(!$result)
	{
		echo 'Błąd zapytania.';
		exit;
	}
	
	echo '<table>';
	$rows = $result->num_rows;
	for($i=0;$i<$rows;$i++)
	{
		$row = $result->fetch_assoc();
		$dis = $row['distance']/1000;
		echo '<tr><td>'.$row['login'].'</td><td>'.$dis.'</td></tr>';
	}
	echo '</table>';
}

Serwis umożliwia także wybór interwału czasowego i oblicza dla niego statystyki. Jedna z opcji pozwalająca na określenie interwału przez użytkownika, wykorzystuje kalendarz JavaScript:

functionselect_run_period($start,$end)
{
	$db = connect_letsrun();
	if($end == 'week')
	{
	$result = $db->query('SELECT date_time, trackid FROM tracks WHERE userid='.$_SESSION['luserid'].' AND date_time BETWEEN DATE_SUB(NOW(),INTERVAL 7 DAY) AND NOW()');
	}else if($end == 'twoweeks')
	{
	$result = $db->query('SELECT date_time, trackid FROM tracks WHERE userid='.$_SESSION['luserid'].' AND date_time BETWEEN DATE_SUB(NOW(),INTERVAL 14 DAY) AND NOW()');
	}else if($end == 'month')
	{
	$result = $db->query('SELECT date_time, trackid FROM tracks WHERE userid='.$_SESSION['luserid'].' AND date_time BETWEEN DATE_SUB(NOW(),INTERVAL 30 DAY) AND NOW()');
	}else
	{
	$result = $db->query('SELECT date_time, trackid FROM tracks WHERE userid='.$_SESSION['luserid'].' AND date_time BETWEEN "'.$start.'" AND "'.$end.'"');
	}
	
	if(!$result)
	{
		echo 'Błąd zapytania.';
		exit;
	}
	if($result->num_rows == 0)
	{
		echo '<p>Brak tras w wybranym okresie.</p>';
		return false;
	}
	echo '<table cellspacing="10">';
	echo '<tr><td><b>Nr.</b></td><td><b>Data rozpoczęcia biegu</b></td><td><b>Godzina rozpoczęcia biegu</b></td></tr>';
	$rows = $result->num_rows;
	for($i=0;$i<$rows;$i++)
	{
		$row = $result->fetch_assoc();
		$j = $i+1;
		echo '<tr><td align="right"><a href="?q=node/4&track='.$row['trackid'].'">'.$j.'</a></td><td align="center">'.substr($row['date_time'],0,10).'</td><td align="center">'.substr($row['date_time'],11,8).'</td></tr>';
	}
	echo '</table>';
	
	if($end == 'week')
	{
	$result = $db->query('SELECT max(distance),avg(distance),max(time),avg(time),max(maximum_speed),sum(distance),sum(time) FROM tracks WHERE userid='.$_SESSION['luserid'].' AND date_time BETWEEN DATE_SUB(NOW(),INTERVAL 7 DAY) AND NOW()');
	}else if($end == 'twoweeks')
	{
	$result = $db->query('SELECT max(distance),avg(distance),max(time),avg(time),max(maximum_speed),sum(distance),sum(time) FROM tracks WHERE userid='.$_SESSION['luserid'].' AND date_time BETWEEN DATE_SUB(NOW(),INTERVAL 14 DAY) AND NOW()');
	}else if($end == 'month')
	{
	$result = $db->query('SELECT max(distance),avg(distance),max(time),avg(time),max(maximum_speed),sum(distance),sum(time) FROM tracks WHERE userid='.$_SESSION['luserid'].' AND date_time BETWEEN DATE_SUB(NOW(),INTERVAL 30 DAY) AND NOW()');
	}else
	{
	$result = $db->query('SELECT max(distance),avg(distance),max(time),avg(time),max(maximum_speed),sum(distance),sum(time) FROM tracks WHERE userid='.$_SESSION['luserid'].' AND date_time BETWEEN "'.$start.'" AND "'.$end.'"');
	}
	
	
	
	if(!$result)
	{
		echo 'Błąd zapytania.';
		exit;
	}
	echo '<table>';
	$row = $result->fetch_assoc();
	$dist = $row['sum(distance)']/1000;
	$t = $row['sum(time)'];
	$dis = $row['max(distance)']/1000;
	$dis2 = $row['avg(distance)']/1000;
	echo '<tr><td><b>Najdłuższydystans (km):</b></td><td align="right">'.$dis.'</td></tr>';
	echo '<tr><td><b>Średnidystans (km):</b></td><td align="right">'.number_format($dis2, 2, '.', '').'</td></tr>';
	echo '<tr><td><b>Najdłuższyczasbiegu (min):</b></td><td align="right">'.$row['max(time)'].'</td></tr>';
	echo '<tr><td><b>Średniczasbiegu (min):</b></td><td align="right">'.number_format($row['avg(time)'], 2, '.', '').'</td></tr>';
	echo '<tr><td><b>Najwyższaprędkość (km/h):</b></td><td align="right">'.$row['max(maximum_speed)'].'</td></tr>';
	echo '<tr><td><b>Średniaprędkość (km/h):</b></td><td align="right">'.number_format($dist/min_to_h($t), 2, '.', '').'</td></tr>';
	echo '</table>';
}

Jedną z funkcjonalności serwera, miała być możliwość integracji z klientem mobilnym dla systemu Android. Serwis umożliwia zdalną autoryzację i wstawianie tras do bazy z uwzględnieniem aktualizacji bazy użytkowników, wynikającej z ograniczeń technicznych, nie pozwalających na wykorzystanie triggerów. Funkcja odpowiadająca za wstawianie tras:

/**
*	Inserts new track into database and updates user fields
**/
functionconnect_and_update()
{
	$login = $_POST['login'];
	$password = $_POST['password'];
	$distance = $_POST['distance'];
	$time = $_POST['time'];
	$kcal = $_POST['kcal'];
	$maximum_speed = $_POST['maximum_speed'];
	$date_time = $_POST['date_time'];

	$userid = get_user_id($login,$password);
	if(!$userid)
	{
		echo "log_err";
		return false;
	}
	else
	{
		if(update_tables($userid,$distance,$time,$kcal,$maximum_speed,$date_time))
		{
			echo "ok";
			return false;
		}
		else
		{
			echo "log_err";
			return true;
		}
	}
}

Oraz ich pobieranie:

/**
*	Returns data from users table
**/
functionreturn_stats()
{
	$login = $_POST['login'];
	$password = $_POST['password'];
	$userid = get_user_id($login,$password);
	if(!$userid)
	{
		echo "log_err";
		return false;
	}
	else
	{
		$db = connect_letsrun();
		$result = $db->query('SELECT distance, max_distance, time, max_time, kcal, max_speed, avg_speed FROM users WHERE userid="'.$userid.'"');
		if(!$result)
		{
			echo "log_err";
			return false;
		}
		$value = $result->fetch_assoc();
		$output[]=$value;
		print(json_encode($output));
		return true;
	}
}

Do przesyłania danych pomiędzy serwisem a klientem, wykorzystano format JSON.

3.4 Dalszy rozwój serwisu internetowego

W przyszłości, planowane jest dołączanie kolejnych funkcjonalności do serwisu internetowego. Jedną z nich jest wizualne prezentowanie danych na temat tras i okresów (np. w postaci czytelny wykresów), co mogłoby podnieść atrakcyjność strony. Dobrym pomysłem, jest również dodanie więcej informacji o zalogowanym użytkowniku, takich jak: miejsce zamieszkania, wiek, awatar itp. Jedną z ważniejszych, planowanych funkcjonalności, jest dodanie możliwości zapisu tras na mapach Google, co wymaga jednak bezpośredniego sprzężenia i informacji od aplikacji mobilnej. Od strony marketingowej, planowane jest stworzenie profilu na FB oraz dalsze prace nad szatą graficzną.

b) Aplikacja mobilna

3.5 Baza danych

Baza danych aplikacji mobilnej została zaimplementowana z wykorzystaniem motoru bazy SQLite. Zapytania tworzące bazę, zostały zaprezentowane w projekcie logicznym.

3.6 Formularze

Formularze wykorzystywane w aplikacji mobilnej w dwóch przypadkach: przesyłania wyników biegów na serwer oraz w czasie podglądania ich na telefonie. Walidacja formularza wysyłania wyników na serwer została podzielona na dwa etapy sprawdzenia zawartości pól przez aplikację oraz sprawdzenie statusu logowania na serwer (implementacja zawarta w linki do pól w opisie logicznym).

3.7 Panel sterowania oraz realizacja funkcjonalności

Aplikacja służąca do śledzenia parametrów biegu użytkownika składa się z: Menu Aplikacji pozwalające na nawigację po programie:

Menu wyboru trybu biegu i ekran śledzący:

W menu wyboru biegu użytkownik może wybrać jeden z trzech typów biegu (swobodny, na czas i na dystans), po kliknięci w przycisk start następuje przejście do ekranu śledzenia biegu.

W tym momencie aplikacja próbuje nawiązać połączenie z satelitami systemu GPS, gdy do tego dojdzie, przycisk Start staje się aktywny i użytkownik może rozpocząć pomiar swoich statystyk, które po zakończeniu biegu zostają zapisane w bazie danych aplikacji.

@Override
publicvoidonFinish() {
			
	dbHelper.open();
	int kcal = (int) ((mass*speed*speed)/(2*4.18));
	Calendar cal = newGregorianCalendar();
	String date = cal.get(Calendar.YEAR)+"-"+cal.get(Calendar.MONTH)+"-"+cal.get(Calendar.DAY_OF_MONTH)+"-"+" "+cal.get(Calendar.HOUR_OF_DAY)+":"+cal.get(Calendar.MINUTE)+":"+cal.get(Calendar.SECOND);
	dbHelper.AddToTracks(1, (int) distance_count, time, kcal, (int) speed, speed_max, date, 0);	
		}

Menu podglądu wyników, w tym miejscu użytkownik może przeglądać swoje osiągnięcia w postaci statystyk (ściągniętych z serwera aplikacji webowej) jak i również biegów które odbył w ostatnim czasie.

	private Cursor getScores() {
		
		Cursor cursor = dbHelper.getDatabase().query("tracks", FROM_TRACKS, null , null , null ,null , ORDER_BY);		
		startManagingCursor(cursor);		
		return cursor;
	}
	
	privatevoidsetStats() {
		Cursor cTrack = dbHelper.getStats();
		startManagingCursor(cTrack);
		
		tvDistance.setText(cTrack.getString(cTrack.getColumnIndex("distance")) + "[m]");
		tvMaxDistance.setText(cTrack.getString(cTrack.getColumnIndex("max_distance")) +" [m]");
		tvTime.setText(cTrack.getString(cTrack.getColumnIndex("time")) + " [min]");
		tvMaxTime.setText(cTrack.getString(cTrack.getColumnIndex("max_time")) + " [min]");
		tvKcal.setText(cTrack.getString(cTrack.getColumnIndex("kcal")) + " [kcal]");
		tvMaxKcal.setText(cTrack.getString(cTrack.getColumnIndex("max_kcal")) + " [kcal]");
		tvAvgSpeed.setText(cTrack.getString(cTrack.getColumnIndex("avg_speed")) + " [km/h]");
	}

3.8 Dalszy rozwój aplikacji mobilnej

W najbliższym okresie planowana jest przebudowa aplikacji tak by, przy jej pomocy możliwe było zapis przebiegu tras i prezentacja ich na mapach Google, jak przesłanie na serwer aplikacji webowej.

3.9 Wnioski

Pisanie pod system operacyjny Android ma dwa aspekty. Po pierwsze można w bardzo łatwy sposób wdrożyć się w to środowisko gdyż w całości oparte jest na języku JAVA i XML służącemu do modelowania wyglądu graficznego aplikacji (co bardzo ułatwia prace nad aplikacją). Dodatkowym atutem jest również mocne wsparcie producenta dla swojego systemu- firmy Google, jak i środowiska programistów piszących na tą platformę. Jednak nadal poważnym problemem pozostaje szybko zmieniające się specyfikacja środowiska i brak dobrych edytorów do edycji warstwy wizualnej aplikacji. Poważnym problemem jest również bardzo „ciężki” dla systemów operacyjnych emulator telefonów opartych na Androidzie. Emulator ma również tendencje do zniekształcania wyglądu aplikacji, który po zainstalowaniu na rzeczywistym telefonie prezentuje się całkowicie prawidłowo.

4 Literatura

  1. Building powerful and robust websites with Drupal, David Mercer
  2. Web performance tuning, Patrick Killelea
  3. PHP + MySQL:Tworzenie stron WWW, Luke Welling

Strona projektu i dokumenty

http://www.letsrun.pl/ - serwis internetowy (wersja beta, bez możliwości pobrania aplikacji mobilnej)

let_srun-koncowy.pdf

pl/dydaktyka/ztb/2011/projekty/letsrun/start.txt · ostatnio zmienione: 2019/06/27 15:50 (edycja zewnętrzna)
www.chimeric.de Valid CSS Driven by DokuWiki do yourself a favour and use a real browser - get firefox!! Recent changes RSS feed Valid XHTML 1.0