diff --git a/android-app/app/.gitignore b/android-app/app/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/android-app/app/.gitignore @@ -0,0 +1 @@ +/build diff --git a/android-app/app/build.gradle b/android-app/app/build.gradle new file mode 100644 index 0000000..651d122 --- /dev/null +++ b/android-app/app/build.gradle @@ -0,0 +1,61 @@ +apply plugin: 'com.android.application' + +apply plugin: 'kotlin-android-extensions' + +android { + compileSdk 34 + defaultConfig { + applicationId "it.danieleverducci.openweddingapp" + minSdkVersion 21 + targetSdkVersion 34 + versionCode 13 + versionName "1.8" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + defaultConfig{ + vectorDrawables.useSupportLibrary = true + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + buildFeatures { + viewBinding true + buildConfig true + } + + namespace 'it.danieleverducci.openweddingapp' +} + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation 'org.jetbrains.kotlin:kotlin-stdlib:2.3.0' + implementation 'androidx.appcompat:appcompat:1.7.1' + implementation 'androidx.core:core-ktx:1.0.2' + implementation 'com.google.android.material:material:1.13.0' + implementation 'androidx.constraintlayout:constraintlayout:2.2.1' + implementation 'androidx.navigation:navigation-fragment:2.9.7' + implementation 'androidx.navigation:navigation-ui:2.9.7' + implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' + implementation 'androidx.navigation:navigation-fragment-ktx:2.9.7' + implementation 'androidx.navigation:navigation-ui-ktx:2.9.7' + implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' + implementation 'com.journeyapps:zxing-android-embedded:4.3.0@aar' + implementation 'com.google.zxing:core:3.5.4' + implementation 'androidx.preference:preference:1.2.1' + implementation 'com.squareup.picasso:picasso:2.71828' + implementation 'com.github.chrisbanes:PhotoView:2.3.0' + implementation 'com.android.volley:volley:1.2.1' + def work_version = "2.7.1" + implementation "androidx.work:work-runtime-ktx:$work_version" + + // OSMDroid + implementation 'org.osmdroid:osmdroid-android:6.1.20' + +} diff --git a/android-app/app/proguard-rules.pro b/android-app/app/proguard-rules.pro new file mode 100644 index 0000000..f1b4245 --- /dev/null +++ b/android-app/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/android-app/app/release/app-release.aab b/android-app/app/release/app-release.aab new file mode 100644 index 0000000..82bb9f2 Binary files /dev/null and b/android-app/app/release/app-release.aab differ diff --git a/android-app/app/src/androidTest/java/it/danieleverducci/danieleeromina/ExampleInstrumentedTest.kt b/android-app/app/src/androidTest/java/it/danieleverducci/danieleeromina/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..c1ed89d --- /dev/null +++ b/android-app/app/src/androidTest/java/it/danieleverducci/danieleeromina/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package it.danieleverducci.openweddingapp + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("it.danieleverducci.openweddingapp", appContext.packageName) + } +} diff --git a/android-app/app/src/main/AndroidManifest.xml b/android-app/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..04416d3 --- /dev/null +++ b/android-app/app/src/main/AndroidManifest.xml @@ -0,0 +1,114 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android-app/app/src/main/ic_launcher-playstore.png b/android-app/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..cbd3e77 Binary files /dev/null and b/android-app/app/src/main/ic_launcher-playstore.png differ diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/DeRApplication.kt b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/DeRApplication.kt new file mode 100644 index 0000000..8bd0f28 --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/DeRApplication.kt @@ -0,0 +1,36 @@ +package it.danieleverducci.openweddingapp + +import android.app.Application +import it.danieleverducci.openweddingapp.entities.RemoteSettings +import it.danieleverducci.openweddingapp.entities.Token +import it.danieleverducci.openweddingapp.utils.SharedPreferencesManager + +class DeRApplication: Application() { + var token: Token? + set(value) { + if (value != null) + SharedPreferencesManager.saveToken(this, value) + else + SharedPreferencesManager.deleteToken(this) + } + get() { + val t = SharedPreferencesManager.loadToken(this) + if (t != null) { + return t + } + return null + } + + val remoteSettings: RemoteSettings + get() { + return SharedPreferencesManager.loadRemoteSettings(this) + } + + // Clears all the user data and cache + fun clearData(){ + SharedPreferencesManager.clear(this) + } + + // Store true if the user was already asked to connect to wifi connection until the app is restarted + var askedConnectingToWeddingWifi = false +} \ No newline at end of file diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/MainActivity.kt b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/MainActivity.kt new file mode 100644 index 0000000..d315049 --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/MainActivity.kt @@ -0,0 +1,413 @@ +package it.danieleverducci.openweddingapp + +import android.Manifest +import android.app.Application +import android.content.ActivityNotFoundException +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.provider.MediaStore +import android.util.Log +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.view.animation.Animation +import android.view.animation.AnimationUtils +import android.widget.ImageView +import android.widget.TextView +import android.widget.Toast +import androidx.appcompat.app.ActionBarDrawerToggle +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.widget.Toolbar +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider +import androidx.drawerlayout.widget.DrawerLayout +import androidx.navigation.findNavController +import androidx.navigation.ui.* +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import com.android.volley.VolleyError +import com.google.android.material.floatingactionbutton.FloatingActionButton +import com.google.android.material.navigation.NavigationView +import com.squareup.picasso.Picasso +import it.danieleverducci.openweddingapp.entities.GalleryItem +import it.danieleverducci.openweddingapp.entities.RemoteSettings +import it.danieleverducci.openweddingapp.networking.RemoteSettingsNet +import it.danieleverducci.openweddingapp.networking.StaticItemNet +import it.danieleverducci.openweddingapp.ui.LoginActivity +import it.danieleverducci.openweddingapp.ui.gallery.GalleryFragment +import it.danieleverducci.openweddingapp.utils.* +import java.io.File +import java.io.IOException +import java.util.concurrent.TimeUnit + + +class MainActivity : AppCompatActivity() { + companion object { + const val MY_PERMISSIONS_REQUEST = 5862 + val REQUEST_IMAGE_CAPTURE = 19437 + val REQUEST_IMAGE_SELECT_FROM_GALLERY = 53213 + val TAG = "MainActivity" + } + var capturedImageFile: File? = null + lateinit var drawerToggle: ActionBarDrawerToggle + + private lateinit var appBarConfiguration: AppBarConfiguration + private lateinit var drawer: DrawerLayout + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + val toolbar: Toolbar = findViewById(R.id.toolbar) + setSupportActionBar(toolbar) + + initNotificationsWorker() + initUi() + } + + override fun onPostCreate(savedInstanceState: Bundle?) { + super.onPostCreate(savedInstanceState) + drawerToggle.syncState() + } + + override fun onStart() { + super.onStart() + checkStagingInstance() + updateRemoteSettings() + + // If first run + if (SharedPreferencesManager.isFirstAppRun(this)) { + // go to Info fragment + val navController = findNavController(R.id.nav_host_fragment) + navController.navigate(R.id.nav_info) + // show menu after 1 sec + Handler(Looper.getMainLooper()).postDelayed({ + drawer.openDrawer(Gravity.LEFT, true) + }, 1000) + } + } + + override fun onBackPressed() { + val navController = findNavController(R.id.nav_host_fragment) + // If already on first screen, show menu instead of closing app right away + if (navController.currentDestination?.id == R.id.nav_gallery && !drawer.isDrawerOpen(Gravity.LEFT)) { + drawer.openDrawer(Gravity.LEFT, true) + return + } + super.onBackPressed() + } + + private fun initNotificationsWorker() { + val wReq = PeriodicWorkRequestBuilder(30, TimeUnit.MINUTES) + .build() + WorkManager.getInstance(this).enqueueUniquePeriodicWork("dernotif", + ExistingPeriodicWorkPolicy.REPLACE, wReq) + } + + private fun initUi() { + val navView: NavigationView = findViewById(R.id.nav_view) + drawer = findViewById(R.id.drawer_layout) + val navController = findNavController(R.id.nav_host_fragment) + + // Set drawer toggle button + supportActionBar!!.setDisplayHomeAsUpEnabled(true) + drawerToggle = ActionBarDrawerToggle(this, drawer, R.string.navigation_drawer_open, R.string.navigation_drawer_close) + drawer.addDrawerListener(drawerToggle) + drawerToggle.syncState() + + // Passing each menu ID as a set of Ids because each + // menu should be considered as top level destinations. + appBarConfiguration = AppBarConfiguration( + setOf( + R.id.nav_gallery, R.id.nav_presence, R.id.nav_location, R.id.nav_food_menu, + R.id.nav_wedding_gift, R.id.nav_info, R.id.nav_attendee_gift, + R.id.nav_privacy, R.id.nav_table, R.id.nav_places + ), drawer + ) + setupActionBarWithNavController(navController, appBarConfiguration) + navView.setupWithNavController(navController) + navView.setNavigationItemSelectedListener { + drawer.closeDrawer(Gravity.LEFT, true) + + // If navigating to the same fragment, do nothing + if(it.itemId == navController.currentDestination?.id) + return@setNavigationItemSelectedListener true + + // Otherwise, navigate to fragment and close drawer + return@setNavigationItemSelectedListener NavigationUI.onNavDestinationSelected(it, navController) + } + + // Set user name and picture + val headerView = navView.getHeaderView(0) + val user = (application as DeRApplication).token?.user + if (user == null) { + startActivity(Intent(this, LoginActivity::class.java)) + finish() + return + } + headerView.findViewById(R.id.nav_header_user_name).text = "${user.name} ${user.surname}" + + if (user.picture != null && user.picture.isNotEmpty()) { + val userBadge = headerView.findViewById(R.id.nav_header_user_badge) + userBadge.visibility = View.VISIBLE + Picasso.get() + .load(user.pictureUrl) + .placeholder(R.drawable.placeholder) + .error(R.drawable.placeholder) + .into(userBadge) + } else { + val userBadge = headerView.findViewById(R.id.nav_header_user_text_badge) + userBadge.visibility = View.VISIBLE + userBadge.text = user.name.substring(0,1) + ViewUtils.colorizeUserBadge(userBadge, user) + } + + // Take Photo Fab onClick + val fabTakePhoto: FloatingActionButton = findViewById(R.id.fab_take_photo) + fabTakePhoto.setOnClickListener { _ -> + // Check if the photo sharing is enabled + if ( + !(application as DeRApplication).remoteSettings.photoSharingEnabled && + !(application as DeRApplication).token!!.user.admin + ) { + // Display "photo sharing will be enabled on wedding day" + showPhotoSharindDisabledDialog() + return@setOnClickListener + } + // Request camera permission + if (ContextCompat.checkSelfPermission(this, + Manifest.permission.CAMERA) + != PackageManager.PERMISSION_GRANTED) { + // Request permission + ActivityCompat.requestPermissions(this, + arrayOf(Manifest.permission.CAMERA), + MY_PERMISSIONS_REQUEST) + } else { + // Permission has already been granted: acquire photo + acquirePhoto(); + } + } + + // Select Photo Fab onClick + val fabSelectPhoto: FloatingActionButton = findViewById(R.id.fab_select_photo) + fabSelectPhoto.setOnClickListener { _ -> + // Check if the photo sharing is enabled + if ( + !(application as DeRApplication).remoteSettings.photoSharingEnabled && + !(application as DeRApplication).token!!.user.admin + ) { + // Display "photo sharing will be enabled on wedding day" + showPhotoSharindDisabledDialog() + return@setOnClickListener + } + val intent = Intent() + intent.type = "image/*" + intent.action = Intent.ACTION_GET_CONTENT + intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) + startActivityForResult(Intent.createChooser(intent, getString(R.string.photouploading_fab_intent)), REQUEST_IMAGE_SELECT_FROM_GALLERY) + } + } + + private fun acquirePhoto() { + // Switch fragment to social + val navController = findNavController(R.id.nav_host_fragment) + navController.navigate(R.id.nav_gallery) + + // Start capture activity + Intent(MediaStore.ACTION_IMAGE_CAPTURE).also { takePictureIntent -> + takePictureIntent.resolveActivity(packageManager)?.also { + val photoFile: File? = try { + capturedImageFile = FileUtils.createImageFile(this) + capturedImageFile + } catch (ex: IOException) { + Log.e(TAG, "Unable to create temp camera file: ${ex}") + null + } + // Continue only if the File was successfully created + photoFile?.also { + val photoURI: Uri = FileProvider.getUriForFile( + this, + "it.danieleverducci.openweddingapp.fileprovider", + it + ) + takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, photoURI) + startActivityForResult(takePictureIntent, REQUEST_IMAGE_CAPTURE) + } + } + } + } + + override fun onSupportNavigateUp(): Boolean { + val navController = findNavController(R.id.nav_host_fragment) + return navController.navigateUp(appBarConfiguration) || super.onSupportNavigateUp() + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + + // User took photo from app + if (requestCode == REQUEST_IMAGE_CAPTURE && resultCode == RESULT_OK && capturedImageFile != null) { + // Some camera apps (like OpenCamera) returns the image in the standard sensor orientation and save the rotation in the exif data. + // In this case, we must rotate the image on our own. + if ((application as DeRApplication).remoteSettings.rotateToExifData) + ImageUtils.rotateImageIfRequired(capturedImageFile!!) + + val gi = GalleryItem(capturedImageFile!!.path, capturedImageFile!!.path, (application as DeRApplication).token!!.user) + + // Add photo to list + val navHost = supportFragmentManager.findFragmentById(R.id.nav_host_fragment) + navHost?.let { navFragment -> + navFragment.childFragmentManager.primaryNavigationFragment?.let {currentFragment-> + if (currentFragment is GalleryFragment) { + currentFragment.onPhotoTaken(gi) + } + } + } + capturedImageFile = null + } + + // User selected photo from gallery (initiated from app) + if (requestCode == REQUEST_IMAGE_SELECT_FROM_GALLERY && resultCode == RESULT_OK && data != null) { + val shareIntent = Intent(this, ShareActivity::class.java) + shareIntent.type = "image/*" + if (data.data != null) { + // Single image + shareIntent.action = Intent.ACTION_SEND + shareIntent.putExtra(Intent.EXTRA_STREAM, data.data) + } else if (data.clipData != null) { + // Multiple images + shareIntent.action = Intent.ACTION_SEND_MULTIPLE + val uris = ArrayList() + for (i in 0..data.clipData!!.itemCount - 1) + uris.add(data.clipData!!.getItemAt(i).uri) + shareIntent.putExtra(Intent.EXTRA_STREAM, uris) + } else { + // Invalid intent + Toast.makeText(this, R.string.photouploading_unsupported, Toast.LENGTH_SHORT).show() + return + } + startActivity(shareIntent) + } + } + + public fun onLogoutButtonClicked(v: View) { + logout() + } + + public fun logout() { + // Remove token + (application as DeRApplication).token = null + // Remove data + (application as DeRApplication).clearData() + // Go to login + val i = Intent(this, LoginActivity::class.java) + startActivity(i) + finish() + } + + public fun showFab(show: Boolean) { + animateFab(findViewById(R.id.fab_take_photo), show) + animateFab(findViewById(R.id.fab_select_photo), show) + } + + private fun animateFab(view: View, show: Boolean) { + val animation = AnimationUtils.loadAnimation(this, + if (show) R.anim.slide_in_right else R.anim.slide_out_right + ) + animation.setAnimationListener(object : Animation.AnimationListener { + override fun onAnimationStart(p0: Animation?) {} + + override fun onAnimationEnd(p0: Animation?) { + view.visibility = if (show) View.VISIBLE else View.GONE + } + + override fun onAnimationRepeat(p0: Animation?) {} + + }) + view.startAnimation(animation) + } + + private fun showPhotoSharindDisabledDialog() { + val builder: AlertDialog.Builder = AlertDialog.Builder(this@MainActivity) + val dialogView: View = + LayoutInflater.from(this@MainActivity).inflate(R.layout.dialog_photosharingdisabled, null, false) + builder.setView(dialogView) + val alertDialog: AlertDialog = builder.create() + alertDialog.show() + } + + private fun updateRemoteSettings() { + RemoteSettingsNet(this@MainActivity).getItem(object: StaticItemNet.OnItemObtainedListener { + override fun OnItemObtained(item: RemoteSettings) { + val rsLocalVersion = (application as DeRApplication).remoteSettings.version + if(item.version > rsLocalVersion) { + SharedPreferencesManager.saveRemoteSettings(this@MainActivity, item) + Log.d(TAG, "Updated remoteSettings: local ${rsLocalVersion}, remote ${item.version}") + checkAppIsUpToDate() + } else { + Log.d(TAG, "RemoteSettings up to date: local ${rsLocalVersion}, remote ${item.version}") + checkAppIsUpToDate() + } + } + + override fun OnError(error: VolleyError) { + if (error is com.android.volley.NoConnectionError) { + val app = (application as DeRApplication) + if (!app.askedConnectingToWeddingWifi) { + WifiUtils.askToAddWeddingNetworks(this@MainActivity, app.remoteSettings.wifiNetworks) + app.askedConnectingToWeddingWifi = true + } + } else { + Log.d(TAG, "Unable to update remote settings: ${error}") + } + } + + }) + } + + private fun checkAppIsUpToDate() { + if (BuildConfig.VERSION_CODE < (application as DeRApplication).remoteSettings.appVersion) { + // Notify user about the update + this.let { + val builder = AlertDialog.Builder(it, R.style.AlertDialogCustom) + builder.apply { + setPositiveButton(android.R.string.ok) { _, _ -> + // User clicked OK button + try { + startActivity( + Intent( + Intent.ACTION_VIEW, + Uri.parse("market://details?id=$packageName") + ) + ) + } catch (e: ActivityNotFoundException) { + startActivity( + Intent( + Intent.ACTION_VIEW, + Uri.parse("https://play.google.com/store/apps/details?id=$packageName") + ) + ) + } + } + setNegativeButton(android.R.string.cancel) { dialog, _ -> + dialog.dismiss() + } + } + builder.setMessage(R.string.update_dialog_message) + .setTitle(R.string.update_dialog_title).create() + builder.show() + } + } + } + + private fun checkStagingInstance() { + if (Config.BASE_URL.contains("staging")) + Toast.makeText(this, "!!! STAGING INSTANCE !!!", Toast.LENGTH_LONG).show() + } +} diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/ShareActivity.kt b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/ShareActivity.kt new file mode 100644 index 0000000..9144b65 --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/ShareActivity.kt @@ -0,0 +1,142 @@ +package it.danieleverducci.openweddingapp + +import android.app.Service +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.os.Parcelable +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import com.google.android.material.snackbar.Snackbar +import it.danieleverducci.openweddingapp.databinding.ActivityShareBinding +import it.danieleverducci.openweddingapp.databinding.FragmentPresenceBinding +import it.danieleverducci.openweddingapp.entities.GalleryItem +import it.danieleverducci.openweddingapp.networking.GalleryItemNet +import it.danieleverducci.openweddingapp.ui.gallery.GalleryFragment +import it.danieleverducci.openweddingapp.utils.FileUtils +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.util.* + +class ShareActivity: AppCompatActivity() { + companion object { + val TAG = "ShareActivity" + } + + protected lateinit var binding: ActivityShareBinding + protected val uploadQueue = LinkedList() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + binding = ActivityShareBinding.inflate( + getSystemService(Service.LAYOUT_INFLATER_SERVICE) as LayoutInflater, null, false) + setContentView(binding.getRoot()) + + // Check for image(s) share intents + if(intent.type?.startsWith("image/") != true) { + Toast.makeText(this, R.string.photouploading_unsupported, Toast.LENGTH_SHORT).show() + finish() + return + } + + when { + intent?.action == Intent.ACTION_SEND -> { + (intent.getParcelableExtra(Intent.EXTRA_STREAM) as? Uri)?.let { + uploadQueue.addLast(it) + upload() + } + } + intent?.action == Intent.ACTION_SEND_MULTIPLE -> { + intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)?.let { + for (uri in it) { + uploadQueue.addLast(uri as Uri) + } + // Progression bar is not indeterminate + binding.shareProgressDet.visibility = View.VISIBLE + binding.shareProgressDet.max = uploadQueue.size + upload() + } + } + } + } + + private fun upload() { + val uri = uploadQueue.pop() + + // Update progression bar + binding.shareProgressDet.progress = binding.shareProgressDet.max - uploadQueue.size + + val photoFile: File? = try { + FileUtils.createTempImageFile(this) + } catch (ex: IOException) { + Log.e(MainActivity.TAG, "Unable to create temp camera file: ${ex}") + null + } + // Continue only if the File was successfully created + photoFile?.also { + val inStr: InputStream? = contentResolver.openInputStream(uri) + if (inStr == null) { + Log.e(TAG, "Error occurred while creating file ${uri.toString()}") + } + + // Copy uri content to temporary file + val outStr: OutputStream = photoFile.outputStream() + inStr!!.use { input -> + outStr.use { output -> + input.copyTo(output) + } + } + + // Upload file + val token = (applicationContext as DeRApplication).token?.token + if (token == null) { + Log.e(GalleryFragment.TAG, "Unable to post photo: no token!") + return + } + val galleryItemNet = GalleryItemNet(this, token) + galleryItemNet.uploadItem( + token, + it, + object : GalleryItemNet.OnGalleryItemUploadCompletedListener { + override fun OnGalleryItemUploaded(uploadedGi: GalleryItem?, error: String) { + if (!error.isEmpty()) { + Toast.makeText(applicationContext, "${getString(R.string.upload_galleryitem_error)}: ${error}", Toast.LENGTH_SHORT).show() + } else if (uploadedGi == null) { + Toast.makeText(applicationContext, getString(R.string.upload_galleryitem_error), Toast.LENGTH_SHORT).show() + } + // Cleanup + photoFile.delete() + + // Process next photo (if any) + if (uploadQueue.size > 0) { + upload() + } else { + onUploadComplete() + } + + } + } + ) + } + } + + protected fun onUploadComplete() { + binding.shareLoading.visibility = View.GONE + binding.shareProgressInd.visibility = View.GONE + binding.shareProgressDet.visibility = View.GONE + binding.shareComplete.visibility = View.VISIBLE + + Handler(Looper.getMainLooper()).postDelayed({ + finish() + }, 1000) + } + +} \ No newline at end of file diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/config.kt b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/config.kt new file mode 100644 index 0000000..be0ff3e --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/config.kt @@ -0,0 +1,34 @@ +package it.danieleverducci.openweddingapp + +object Config { + const val BASE_URL = "https://rominaedaniele.ichibi.eu" + + const val SAVED_FILES_BASE_NAME = "OpenWeddingApp" + const val API_BASE_URL = BASE_URL + const val API_ENDPOINT_GALLERY_ITEM = "/api/gallery_item" + const val API_ENDPOINT_TOKEN = "/api/token" + const val API_ENDPOINT_LIKE = "/api/like" + const val API_ENDPOINT_PRESENCE = "/api/presence" + const val API_ENDPOINT_TABLE = "/api/table" + const val STATIC_ENDPOINT_LOCATION = "/static/location.json" + const val STATIC_ENDPOINT_PLACES = "/static/places.json" + const val STATIC_ENDPOINT_WEDDING_GIFT = "/static/wedding-gift.json" + const val STATIC_ENDPOINT_ATTENDEE_GIFT = "/static/attendee-gift.json" + const val STATIC_ENDPOINT_FOOD_MENU = "/static/food-menu.json" + const val STATIC_ENDPOINT_SETTINGS = "/static/settings.json" + const val STATIC_CONTENT_DEFAULT_LOCALE = "en" // Defalt locale to show when the device uses an unsupported locale + + const val MEDIA_BASE_URL = BASE_URL + const val USER_PICTURE_PATH = "/user-pictures/" + + const val MAX_UPLOAD_IMAGE_SIZE = 10*1024*1024; // Maximum upload size for php services is set to 20M + + // Notifications + const val NTFY_BASE_URL = "https://ntfy.sh/" + // Ntfy topics + const val NTFY_GENERAL_TOPIC = "my_private_ntfy_topic" // All users + const val NTFY_DNR_TOPIC = "my_private_ntfy_topic" // Users that did not respond to "will be present?" + const val NTFY_WILLBETHERE_TOPIC = "my_private_ntfy_topic" // Users that will be present + const val NTFY_WILLNOTBETHERE_TOPIC = "my_private_ntfy_topic" // Users that will not be present + +} \ No newline at end of file diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/entities/GalleryItem.kt b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/entities/GalleryItem.kt new file mode 100644 index 0000000..c2801e0 --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/entities/GalleryItem.kt @@ -0,0 +1,94 @@ +package it.danieleverducci.openweddingapp.entities + +import it.danieleverducci.openweddingapp.Config +import org.json.JSONException +import org.json.JSONObject +import java.text.SimpleDateFormat +import java.time.LocalDate +import java.util.* + +/* + JSON Example: + { + "imageUrl": "https://picsum.photos/200", + "likes": 23, + "description": "Descrizione della foto", + "author": 0 + } + */ + +class GalleryItem: Jsonable { + val id: Int + val imageUrl: String + val imageThumbUrl: String + val likes: Int + val firstUserLiked: User? + val description: String + val author: User + val created: Date + var currentUserLike: Like? + + val url: String + get() { + if (imageUrl.startsWith("http")) { + return imageUrl + } else { + return "${Config.MEDIA_BASE_URL}${imageUrl}" + } + } + + val thumbUrl: String + get() { + if (imageThumbUrl.startsWith("http")) { + return imageThumbUrl + } else { + return "${Config.MEDIA_BASE_URL}${imageThumbUrl}" + } + } + + @Throws(JSONException::class) + constructor(jo: JSONObject) { + this.id = jo.getInt("id") + this.imageUrl = jo.getString("imageUrl") + this.imageThumbUrl = jo.getString("imageThumbUrl") + this.likes = jo.getInt("likes") + val fujo = jo.optJSONObject("firstUserLiked") + if(fujo != null) { + this.firstUserLiked = User(fujo) + } else { + this.firstUserLiked = null + } + this.description = if (jo.isNull("description") || jo.optString("description") == "null") "" else jo.optString("description") + this.author = User(jo.getJSONObject("author")) + this.created = SimpleDateFormat("yyyy-MM-dd hh:mm:ss").parse(jo.getString("created")) + this.currentUserLike = if (jo.optJSONObject("currentUserLike") == null) null else Like(jo.optJSONObject("currentUserLike")) + } + + @Throws(JSONException::class) + constructor(imageUrl: String, imageThumbUrl: String, author: User) { + this.id = 0 + this.imageUrl = imageUrl + this.imageThumbUrl = imageThumbUrl + this.author = author + this.likes = 0 + this.firstUserLiked = null + this.description = "" + this.created = Date() + this.currentUserLike = null + } + + override fun toJson(): JSONObject { + val jo = JSONObject() + jo.put("id", id) + jo.put("imageUrl", imageUrl) + jo.put("imageThumbUrl", imageThumbUrl) + jo.put("author", author.toJson()) + jo.put("likes", likes) + jo.put("firstUserLiked", firstUserLiked?.toJson()) + jo.put("description", description) + jo.put("created", SimpleDateFormat("yyyy-MM-dd hh:mm:ss").format(created)) + jo.put("currentUserLiked", currentUserLike) + return jo + } + +} \ No newline at end of file diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/entities/GenericDynamicContent.kt b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/entities/GenericDynamicContent.kt new file mode 100644 index 0000000..1c5c29e --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/entities/GenericDynamicContent.kt @@ -0,0 +1,70 @@ +package it.danieleverducci.openweddingapp.entities + +import it.danieleverducci.openweddingapp.Config +import org.json.JSONException +import org.json.JSONObject + +class GenericDynamicContent: Jsonable { + val localized = HashMap() + val version: Int + + /** + * Constructs a GenericDynamicContent object + * @param wgJo: JSONObject returned by the services + */ + @Throws(JSONException::class) + constructor(wgJo: JSONObject) { + val localizedGenericDynamicContents = wgJo.getJSONObject("localized") + for (localeId in localizedGenericDynamicContents.keys()) { + localized.put(localeId, LocalizedGenericDynamicContent(localizedGenericDynamicContents.getJSONObject(localeId))) + } + version = wgJo.getInt("version") + } + + override fun toJson(): JSONObject { + val localizedJo = JSONObject() + for ((localeId, location) in localized) { + localizedJo.put(localeId, location.toJson()) + } + val jo = JSONObject() + jo.put("localized", localizedJo) + jo.put("version", version) + return jo + } +} + +class LocalizedGenericDynamicContent: Jsonable { + val name: String + val content: String + val picture: String + + /** + * Constructs a Localized object + * @param wgJo: JSONObject returned by the services + * @param locale: two-letter international code (i.e. "it" or "es") + */ + @Throws(JSONException::class) + constructor(jo: JSONObject) { + this.name = jo.getString("name") + this.content = jo.getString("content") + this.picture = jo.getString("picture") + } + + override fun toJson(): JSONObject { + val jo = JSONObject() + jo.put("name", this.name) + jo.put("content", this.content) + jo.put("picture", this.picture) + return jo + } + + val pictureUrl: String? + get() { + if (picture.startsWith("http")) { + return picture + } else { + return "${Config.MEDIA_BASE_URL}/static/media/${picture}" + } + } + +} \ No newline at end of file diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/entities/Jsonable.kt b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/entities/Jsonable.kt new file mode 100644 index 0000000..814b396 --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/entities/Jsonable.kt @@ -0,0 +1,7 @@ +package it.danieleverducci.openweddingapp.entities + +import org.json.JSONObject + +interface Jsonable { + fun toJson(): JSONObject +} \ No newline at end of file diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/entities/Like.kt b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/entities/Like.kt new file mode 100644 index 0000000..cc11d90 --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/entities/Like.kt @@ -0,0 +1,38 @@ +package it.danieleverducci.openweddingapp.entities + +import org.json.JSONException +import org.json.JSONObject +import java.text.SimpleDateFormat +import java.util.* + +class Like: Jsonable { + val id: Int + val galleryId: Int + val userId: Int + val created: Date + + @Throws(JSONException::class) + constructor(jo: JSONObject) { + this.id = jo.getInt("id") + this.galleryId = jo.getInt("gallery_id") + this.userId = jo.getInt("user_id") + this.created = SimpleDateFormat("yyyy-MM-dd hh:mm:ss").parse(jo.getString("created")) + } + + + constructor(galleryId: Int, userId: Int) { + this.id = 0 + this.galleryId = galleryId + this.userId = userId + this.created = Date() + } + + override fun toJson(): JSONObject { + val jo = JSONObject() + jo.put("id", this.id) + jo.put("gallery_id", this.galleryId) + jo.put("user_id", this.userId) + jo.put("created", this.created) + return jo + } +} \ No newline at end of file diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/entities/Location.kt b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/entities/Location.kt new file mode 100644 index 0000000..5fa4c7c --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/entities/Location.kt @@ -0,0 +1,100 @@ +package it.danieleverducci.openweddingapp.entities + +import android.net.Uri +import com.android.volley.toolbox.JsonObjectRequest +import it.danieleverducci.openweddingapp.Config +import org.json.JSONException +import org.json.JSONObject + +class Location: Jsonable { + val localized = HashMap() + val version: Int + + /** + * Constructs a Location object + * @param locJo: JSONObject returned by the services + */ + @Throws(JSONException::class) + constructor(locJo: JSONObject) { + val localizedLocations = locJo.getJSONObject("localized") + for (localeId in localizedLocations.keys()) { + localized.put(localeId, LocalizedLocation(localizedLocations.getJSONObject(localeId))) + } + version = locJo.getInt("version") + } + + override fun toJson(): JSONObject { + val localizedJo = JSONObject() + for ((localeId, location) in localized) { + localizedJo.put(localeId, location.toJson()) + } + val jo = JSONObject() + jo.put("localized", localizedJo) + jo.put("version", version) + return jo + } +} + +class LocalizedLocation: Jsonable { + val name: String + val content: String + val picture: String + val coordinates: Array + + /** + * Constructs a Localized Location object + * @param locJo: JSONObject returned by the services + * @param locale: two-letter international code (i.e. "it" or "es") + */ + @Throws(JSONException::class) + constructor(jo: JSONObject) { + this.name = jo.getString("name") + this.content = jo.getString("content") + this.picture = jo.getString("picture") + this.coordinates = arrayOf( + jo.getJSONObject("coordinates").getDouble("lat"), + jo.getJSONObject("coordinates").getDouble("lon") + ) + } + + override fun toJson(): JSONObject { + val jo = JSONObject() + jo.put("name", this.name) + jo.put("content", this.content) + jo.put("picture", this.picture) + val coordJo = JSONObject() + coordJo.put("lat", this.coordinates[0]) + coordJo.put("lon", this.coordinates[1]) + jo.put("coordinates", coordJo) + return jo + } + + val pictureUrl: String? + get() { + if (picture.startsWith("http")) { + return picture + } else { + return "${Config.MEDIA_BASE_URL}/static/media/${picture}" + } + } + + val geoUri: Uri + get() { + val lat = this.coordinates[0] + val lng = this.coordinates[1] + return Uri.parse( + "geo:" + lat + "," + lng + + "q=" + lat + "," + lng + "(" + name + ")" + ) + } + + val gooleMapsUri: Uri + get() { + val lat = this.coordinates[0] + val lng = this.coordinates[1] + return Uri.parse( + "https://www.google.com/maps/search/?api=1&query=$lat,$lng" + ) + } + +} \ No newline at end of file diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/entities/MapPlace.kt b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/entities/MapPlace.kt new file mode 100644 index 0000000..f3c9783 --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/entities/MapPlace.kt @@ -0,0 +1,23 @@ +package it.danieleverducci.openweddingapp.entities + +import org.json.JSONException +import org.json.JSONObject + +class MapPlace { + var name: String + var descr: String + var lat: Double + var lon: Double + var type: String + var time: String + + @Throws(JSONException::class) + constructor(jo: JSONObject) { + this.name = jo.getString("name") + this.descr = jo.getString("descr") + this.lat = jo.getDouble("lat") + this.lon = jo.getDouble("lon") + this.type = jo.getString("type") + this.time = jo.getString("time") + } +} \ No newline at end of file diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/entities/NtfyMessage.kt b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/entities/NtfyMessage.kt new file mode 100644 index 0000000..89981da --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/entities/NtfyMessage.kt @@ -0,0 +1,36 @@ +package it.danieleverducci.openweddingapp.entities + +import org.json.JSONException +import org.json.JSONObject + +class NtfyMessage { + /** + * Represents a Ntfy (see https://ntfy.sh/docs) message + * + * { + * "id": "8FKvDaOZbBPQ", + * "time": 1662446843, + * "event": "message", + * "topic": "topic_name_257y3h53", + * "message": "Lorem ipsum dolor sit amet", + * "priority": 3 + * } + */ + + var id: String + var time: Long + var event: String + var topic: String + var message: String + var priority: Int + + @Throws(JSONException::class) + constructor(jo: JSONObject) { + this.id = jo.getString("id") + this.time = jo.getLong("time") + this.event = jo.getString("event") + this.topic = jo.getString("topic") + this.message = jo.getString("message") + this.priority = jo.getInt("priority") + } +} \ No newline at end of file diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/entities/Presence.kt b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/entities/Presence.kt new file mode 100644 index 0000000..fde71a5 --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/entities/Presence.kt @@ -0,0 +1,37 @@ +package it.danieleverducci.openweddingapp.entities + +import android.util.Log +import org.json.JSONException +import org.json.JSONObject + +/* + JSON Example: + { + "willBePresent": true + } + */ + +class Presence: Jsonable { + val willBePresent: Boolean + val notes: String + + @Throws(JSONException::class) + constructor(willBePresent: Boolean, notes: String) { + this.willBePresent = willBePresent + this.notes = notes + } + + @Throws(JSONException::class) + constructor(locJo: JSONObject) { + willBePresent = locJo.getBoolean("willBePresent") + notes = locJo.getString("notes") + } + + override fun toJson(): JSONObject { + val jo = JSONObject() + jo.put("willBePresent", willBePresent) + jo.put("notes", notes) + return jo + } + +} \ No newline at end of file diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/entities/RemoteSettings.kt b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/entities/RemoteSettings.kt new file mode 100644 index 0000000..d6e2e44 --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/entities/RemoteSettings.kt @@ -0,0 +1,46 @@ +package it.danieleverducci.openweddingapp.entities + +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject + +class RemoteSettings: Jsonable { + val version: Int + val appVersion: Int + val photoSharingEnabled: Boolean + val showTableEnabled: Boolean + val rotateToExifData: Boolean + val wifiNetworks: ArrayList + + @Throws(JSONException::class) + constructor(jo: JSONObject) { + version = jo.getInt("version") + // appVersion was introduced in version config file version 4 + appVersion = jo.optInt("appVersion",9) // 9 is last version previous to update check introduction + photoSharingEnabled = jo.getBoolean("photoSharingEnabled") + showTableEnabled = jo.getBoolean("showTableEnabled") + rotateToExifData = jo.optBoolean("rotateToExifData", true) + wifiNetworks = ArrayList() + if (jo.has("wifiNetworks")) { + val wnja = jo.getJSONArray("wifiNetworks") + for (i in 0 until wnja.length()) { + val wnjo = wnja.getJSONObject(i) + wifiNetworks.add(WifiNetwork(wnjo)) + } + } + } + + override fun toJson(): JSONObject { + val jo = JSONObject() + jo.put("version", version) + jo.put("appVersion", appVersion) + jo.put("photoSharingEnabled", photoSharingEnabled) + jo.put("showTableEnabled", showTableEnabled) + jo.put("rotateToExifData", rotateToExifData) + val wnja = JSONArray() + for (wn in wifiNetworks) + wnja.put(wn.toJson()) + jo.put("wifiNetworks", wnja) + return jo + } +} diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/entities/Table.kt b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/entities/Table.kt new file mode 100644 index 0000000..35bd4b7 --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/entities/Table.kt @@ -0,0 +1,43 @@ +package it.danieleverducci.openweddingapp.entities + +import org.json.JSONObject + +class Table: Jsonable { + val table: String + val count: Int + val people: ArrayList + + constructor(jo: JSONObject) { + this.table = jo.getString("table") + this.count = jo.getInt("count") + val peopleJa = jo.getJSONArray("people") + this.people = ArrayList() + for (i in 0 until peopleJa.length()) + this.people.add(TablePeople(peopleJa.getJSONObject(i))) + } + + override fun toJson(): JSONObject { + TODO("Never used") + } + + fun getPeoplePrintableList(): String { + var pl = "" + this.people.forEach { + if (pl.length > 0) + pl += ", " + pl += it.name + } + return pl + } +} + + +class TablePeople { + val name: String + val surname: String + + constructor(jo: JSONObject) { + this.name = jo.getString("name") + this.surname = jo.getString("surname") + } +} \ No newline at end of file diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/entities/Token.kt b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/entities/Token.kt new file mode 100644 index 0000000..e3aa484 --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/entities/Token.kt @@ -0,0 +1,27 @@ +package it.danieleverducci.openweddingapp.entities + +import org.json.JSONException +import org.json.JSONObject +import java.util.* + +class Token: Jsonable { + val userId: Int + val user: User + val token: String + + @Throws(JSONException::class) + constructor(jo: JSONObject) { + this.userId = jo.getInt("userId") + this.user = User(jo.getJSONObject("user")) + this.token = jo.getString("token") + } + + override fun toJson(): JSONObject { + val jo = JSONObject() + jo.put("userId", this.userId) + jo.put("user", this.user.toJson()) + jo.put("token", this.token) + return jo + } + +} \ No newline at end of file diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/entities/User.kt b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/entities/User.kt new file mode 100644 index 0000000..fcacd79 --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/entities/User.kt @@ -0,0 +1,64 @@ +package it.danieleverducci.openweddingapp.entities + +import android.graphics.Color +import it.danieleverducci.openweddingapp.Config +import it.danieleverducci.openweddingapp.utils.ViewUtils +import org.json.JSONException +import org.json.JSONObject +import java.text.Normalizer + +class User: Jsonable { + val id: Int + val name: String + val surname: String + val code: String + val picture: String? + val admin: Boolean + val table: String + + @Throws(JSONException::class) + constructor(jo: JSONObject) { + this.id = jo.getInt("id") + this.name = jo.getString("name") + this.surname = jo.getString("surname") + this.code = jo.getString("code") + this.picture = if (jo.isNull("picture") || jo.optString("picture") == "null") null else jo.optString("picture") + this.admin = jo.optBoolean("admin", false) + this.table = jo.getString("table") + } + + override fun toJson(): JSONObject { + val jo = JSONObject() + jo.put("id", this.id) + jo.put("name", this.name) + jo.put("surname", this.surname) + jo.put("code", this.code) + jo.put("picture", this.picture) + jo.put("admin", this.admin) + jo.put("table", this.table) + return jo + } + + val pictureUrl: String? + get() { + if (picture == null) + return null + if (picture.startsWith("http")) { + return picture + } else { + return "${Config.MEDIA_BASE_URL}${Config.USER_PICTURE_PATH}${picture}" + } + } + + // Badge color calculated on first 3 name and surname letters + val badgeColor: Int + get() { + val r = (ViewUtils.getUserBadgeColorComponentFromString(name, 0) + ViewUtils.getUserBadgeColorComponentFromString(surname, 0)) / 2 + val g = (ViewUtils.getUserBadgeColorComponentFromString(name, 1) + ViewUtils.getUserBadgeColorComponentFromString(surname, 1)) / 2 + val b = (ViewUtils.getUserBadgeColorComponentFromString(name, 2) + ViewUtils.getUserBadgeColorComponentFromString(surname, 2)) / 2 + return Color.rgb(r, g, b) + } + + + +} \ No newline at end of file diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/entities/WifiNetwork.kt b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/entities/WifiNetwork.kt new file mode 100644 index 0000000..479f4f0 --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/entities/WifiNetwork.kt @@ -0,0 +1,23 @@ +package it.danieleverducci.openweddingapp.entities + +import org.json.JSONObject + +/** + * A wifi network provided by the wedding organization or restaurant + */ +class WifiNetwork: Jsonable { + val ssid: String + val password: String + + constructor(jo: JSONObject) { + this.ssid = jo.getString("ssid") + this.password = jo.getString("password") + } + + override fun toJson(): JSONObject { + val json = JSONObject() + json.put("ssid", ssid) + json.put("password", password) + return json + } +} \ No newline at end of file diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/networking/AuthenticatedItemNet.kt b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/networking/AuthenticatedItemNet.kt new file mode 100644 index 0000000..2a13a48 --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/networking/AuthenticatedItemNet.kt @@ -0,0 +1,120 @@ +package it.danieleverducci.openweddingapp.networking + +import android.content.Context +import android.util.Log +import com.android.volley.Request +import com.android.volley.RequestQueue +import com.android.volley.Response +import com.android.volley.VolleyError +import com.android.volley.toolbox.Volley +import it.danieleverducci.openweddingapp.entities.Jsonable +import org.json.JSONObject + +open abstract class AuthenticatedItemNet (val context: Context, val token: String) { + companion object { + val TAG = "AuthenticatedItemNet" + } + + val queue: RequestQueue + + init { + queue = Volley.newRequestQueue(context) + } + + abstract fun newItem(jo: JSONObject): T + + open fun getList(baseUrl: String, page: Int, listener: OnItemListObtainedListener) { + val url = "${baseUrl}/read.php?page=${page}" + val request = AuthenticatedJsonObjectRequest( + token, + Request.Method.GET, + url, + null, + Response.Listener { + val records = it.getJSONArray("records") + val page = it.getInt("page") + val more = it.getBoolean("more") + val items = ArrayList(records.length()) + for (i in 0 until records.length()) { + items.add(newItem(records.getJSONObject(i))) + } + listener.OnItemListObtained(items, page, more) + }, + Response.ErrorListener { + listener.OnError(it) + }) + queue.add(request) + } + + open fun getItem(baseUrl: String, id: Int, listener: OnItemObtainedListener) { + val url = "${baseUrl}/get.php?id=${id}" + val request = AuthenticatedJsonObjectRequest( + token, + Request.Method.GET, + url, + null, + Response.Listener { + val item = newItem(it) + listener.OnItemObtained(item) + }, + Response.ErrorListener { + listener.OnError(it) + }) + queue.add(request) + } + + open fun postItem(baseUrl: String, listener: OnItemPostedListener, item: T) { + val request = AuthenticatedJsonObjectRequest( + token, + Request.Method.POST, + "${baseUrl}/create.php", + item.toJson(), + Response.Listener { + listener.OnItemPosted(newItem(it)) + }, + Response.ErrorListener { + Log.e(TAG, "Unable to post item: ${it}") + listener.OnError(it) + } + ) + queue.add(request) + } + + open fun deleteItem(baseUrl: String, listener: OnItemDeletedListener, id: Int) { + val request = AuthenticatedJsonObjectRequest( + token, + Request.Method.DELETE, + "${baseUrl}/delete.php?id=${id}", + null, + Response.Listener { + listener.OnItemDeleted() + }, + Response.ErrorListener { + Log.e(TAG, "Unable to delete item: ${it}") + listener.OnError(it) + } + ) + queue.add(request) + } + + interface OnItemListObtainedListener { + fun OnItemListObtained(items: ArrayList?, page: Int, more: Boolean) + fun OnError(error: VolleyError) + } + + interface OnItemObtainedListener { + fun OnItemObtained(item: T) + fun OnError(error: VolleyError) + } + + interface OnItemPostedListener { + fun OnItemPosted(item: T) + fun OnError(error: VolleyError) + } + + interface OnItemDeletedListener { + fun OnItemDeleted() + fun OnError(error: VolleyError) + } + +} \ No newline at end of file diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/networking/AuthenticatedJsonObjectRequest.kt b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/networking/AuthenticatedJsonObjectRequest.kt new file mode 100644 index 0000000..0d94a82 --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/networking/AuthenticatedJsonObjectRequest.kt @@ -0,0 +1,22 @@ +package it.danieleverducci.openweddingapp.networking + +import com.android.volley.Response +import com.android.volley.toolbox.JsonObjectRequest +import org.json.JSONObject + +class AuthenticatedJsonObjectRequest( + val token: String, + method: Int, + url: String?, + jsonRequest: JSONObject?, + listener: Response.Listener?, + errorListener: Response.ErrorListener? +) : JsonObjectRequest(method, url, jsonRequest, listener, errorListener) { + + override fun getHeaders(): MutableMap { + val h = HashMap() + h.put("Authentication", token) + h.put("Accept", "application/json") + return h + } +} \ No newline at end of file diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/networking/AuthenticatedMultipartRequest.java b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/networking/AuthenticatedMultipartRequest.java new file mode 100644 index 0000000..778ab7e --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/networking/AuthenticatedMultipartRequest.java @@ -0,0 +1,133 @@ +package it.danieleverducci.openweddingapp.networking; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.PrintWriter; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLConnection; +import java.util.ArrayList; +import java.util.List; + +public class AuthenticatedMultipartRequest { + private final String boundary; + private static final String LINE_FEED = "\r\n"; + private HttpURLConnection httpConn; + private String charset; + private OutputStream outputStream; + private PrintWriter writer; + + /** + * This constructor initializes a new HTTP POST request with content type + * is set to multipart/form-data + * + * @param requestURL + * @param charset + * @throws IOException + */ + public AuthenticatedMultipartRequest(String token, String requestURL, String charset) + throws IOException { + this.charset = charset; + + // creates a unique boundary based on time stamp + boundary = "===" + System.currentTimeMillis() + "==="; + URL url = new URL(requestURL); + httpConn = (HttpURLConnection) url.openConnection(); + httpConn.setUseCaches(false); + httpConn.setDoOutput(true); // indicates POST method + httpConn.setDoInput(true); + httpConn.setRequestProperty("Content-Type", + "multipart/form-data; boundary=" + boundary); + httpConn.setRequestProperty("Authentication", token); + outputStream = httpConn.getOutputStream(); + writer = new PrintWriter(new OutputStreamWriter(outputStream, charset), + true); + } + + /** + * Adds a form field to the request + * + * @param name field name + * @param value field value + */ + public void addFormField(String name, String value) { + writer.append("--" + boundary).append(LINE_FEED); + writer.append("Content-Disposition: form-data; name=\"" + name + "\"") + .append(LINE_FEED); + writer.append("Content-Type: text/plain; charset=" + charset).append( + LINE_FEED); + writer.append(LINE_FEED); + writer.append(value).append(LINE_FEED); + writer.flush(); + } + + /** + * Adds a upload file section to the request + * + * @param fieldName name attribute in + * @param uploadFile a File to be uploaded + * @throws IOException + */ + public void addFilePart(String fieldName, File uploadFile) + throws IOException { + String fileName = uploadFile.getName(); + writer.append("--" + boundary).append(LINE_FEED); + writer.append( + "Content-Disposition: form-data; name=\"" + fieldName + + "\"; filename=\"" + fileName + "\"") + .append(LINE_FEED); + writer.append( + "Content-Type: " + + URLConnection.guessContentTypeFromName(fileName)) + .append(LINE_FEED); + writer.append("Content-Transfer-Encoding: binary").append(LINE_FEED); + writer.append(LINE_FEED); + writer.flush(); + + FileInputStream inputStream = new FileInputStream(uploadFile); + byte[] buffer = new byte[4096]; + int bytesRead = -1; + while ((bytesRead = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, bytesRead); + } + outputStream.flush(); + inputStream.close(); + writer.append(LINE_FEED); + writer.flush(); + } + + /** + * Completes the request and receives response from the server. + * + * @return a list of Strings as response in case the server returned + * status OK, otherwise an exception is thrown. + * @throws IOException + */ + public List finish() throws IOException { + List response = new ArrayList(); + writer.append(LINE_FEED).flush(); + writer.append("--" + boundary + "--").append(LINE_FEED); + writer.close(); + + // checks server's status code first + int status = httpConn.getResponseCode(); + if (status == HttpURLConnection.HTTP_OK) { + BufferedReader reader = new BufferedReader(new InputStreamReader( + httpConn.getInputStream())); + String line = null; + while ((line = reader.readLine()) != null) { + response.add(line); + } + reader.close(); + httpConn.disconnect(); + } else { + throw new IOException("Server returned non-OK status: " + status); + } + return response; + } +} \ No newline at end of file diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/networking/GalleryItemNet.kt b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/networking/GalleryItemNet.kt new file mode 100644 index 0000000..9e3add5 --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/networking/GalleryItemNet.kt @@ -0,0 +1,45 @@ +package it.danieleverducci.openweddingapp.networking + +import android.content.Context +import android.os.Handler +import android.util.Log +import com.android.volley.Request +import com.android.volley.RequestQueue +import com.android.volley.Response +import com.android.volley.toolbox.JsonObjectRequest +import com.android.volley.toolbox.Volley +import it.danieleverducci.openweddingapp.Config +import it.danieleverducci.openweddingapp.entities.GalleryItem +import org.json.JSONObject +import java.io.File + + +class GalleryItemNet(context: Context, token: String): AuthenticatedItemNet(context, token) { + companion object { + val TAG = "GalleryItemNet" + } + + fun getList(page: Int, listener: OnItemListObtainedListener) { + val url = "${Config.API_BASE_URL}${Config.API_ENDPOINT_GALLERY_ITEM}" + super.getList(url, page, listener) + } + + fun postItem(listener: OnItemPostedListener, item: GalleryItem) { + val url = "${Config.API_BASE_URL}${Config.API_ENDPOINT_GALLERY_ITEM}" + super.postItem(url, listener, item) + } + + + fun uploadItem(token: String, file: File, listener: OnGalleryItemUploadCompletedListener) { + val uiat = UploadItemAsyncTask(token, listener) + uiat.execute(file) + } + + interface OnGalleryItemUploadCompletedListener { + fun OnGalleryItemUploaded(galleryItem: GalleryItem?, error: String) + } + + override fun newItem(jo: JSONObject): GalleryItem { + return GalleryItem(jo) + } +} \ No newline at end of file diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/networking/GenericDynamicContentNet.kt b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/networking/GenericDynamicContentNet.kt new file mode 100644 index 0000000..69b188c --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/networking/GenericDynamicContentNet.kt @@ -0,0 +1,23 @@ +package it.danieleverducci.openweddingapp.networking + +import android.content.Context +import it.danieleverducci.openweddingapp.Config +import it.danieleverducci.openweddingapp.entities.GenericDynamicContent +import org.json.JSONObject + + +class GenericDynamicContentNet(context: Context): StaticItemNet(context) { + companion object { + val TAG = "GenericDynamicContentNet" + } + + override fun getItem(relUrl: String, listener: OnItemObtainedListener) { + val url = "${Config.API_BASE_URL}${relUrl}" + super.getItem(url, listener) + } + + override fun newItem(jo: JSONObject): GenericDynamicContent { + return GenericDynamicContent(jo) + } + +} \ No newline at end of file diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/networking/LikeNet.kt b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/networking/LikeNet.kt new file mode 100644 index 0000000..5afc9cb --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/networking/LikeNet.kt @@ -0,0 +1,26 @@ +package it.danieleverducci.openweddingapp.networking + +import android.content.Context +import it.danieleverducci.openweddingapp.Config +import it.danieleverducci.openweddingapp.entities.Like +import org.json.JSONObject + + +class LikeNet(context: Context, token: String): AuthenticatedItemNet(context, token) { + companion object { + val TAG = "LikeNet" + val url = "${Config.API_BASE_URL}${Config.API_ENDPOINT_LIKE}" + } + + fun postItem(listener: OnItemPostedListener, item: Like) { + super.postItem(url, listener, item) + } + + fun deleteItem(listener: OnItemDeletedListener, id: Int) { + super.deleteItem(url, listener, id) + } + + override fun newItem(jo: JSONObject): Like { + return Like(jo) + } +} \ No newline at end of file diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/networking/LocationNet.kt b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/networking/LocationNet.kt new file mode 100644 index 0000000..7b959ac --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/networking/LocationNet.kt @@ -0,0 +1,23 @@ +package it.danieleverducci.openweddingapp.networking + +import android.content.Context +import it.danieleverducci.openweddingapp.Config +import it.danieleverducci.openweddingapp.entities.Location +import org.json.JSONObject + + +class LocationNet(context: Context): StaticItemNet(context) { + companion object { + val TAG = "LocationNet" + val url = "${Config.API_BASE_URL}${Config.STATIC_ENDPOINT_LOCATION}" + } + + fun getItem(listener: OnItemObtainedListener) { + super.getItem(url, listener) + } + + override fun newItem(jo: JSONObject): Location { + return Location(jo) + } + +} \ No newline at end of file diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/networking/NtfySubscriptionNet.kt b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/networking/NtfySubscriptionNet.kt new file mode 100644 index 0000000..3b10fd5 --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/networking/NtfySubscriptionNet.kt @@ -0,0 +1,58 @@ +package it.danieleverducci.openweddingapp.networking + +import android.content.Context +import android.util.Log +import com.android.volley.Request +import com.android.volley.RequestQueue +import com.android.volley.Response +import com.android.volley.VolleyError +import com.android.volley.toolbox.JsonObjectRequest +import com.android.volley.toolbox.StringRequest +import com.android.volley.toolbox.Volley +import it.danieleverducci.openweddingapp.entities.Jsonable +import it.danieleverducci.openweddingapp.entities.NtfyMessage +import org.json.JSONObject + +/** + * See Ntfy https://ntfy.sh/docs + */ +class NtfySubscriptionNet (val context: Context) { + companion object { + val TAG = "NtfySubscriptionNet" + } + + val queue: RequestQueue + + init { + queue = Volley.newRequestQueue(context) + } + + fun getItems(url: String, listener: OnItemsObtainedListener) { + val request = StringRequest( + Request.Method.GET, + url, + { + val items = ArrayList() + // Ntfy returns a multi-line string + // Every line is a JSONObject + for (line in it.split("\n")) { + if (line.trim().isNotEmpty()) { + val jo = JSONObject(line) + val item = NtfyMessage(jo) + items.add(item) + } + } + listener.OnItemsObtained(items) + }, + { + listener.OnError(it) + }) + queue.add(request) + } + + interface OnItemsObtainedListener { + fun OnItemsObtained(items: List) + fun OnError(error: VolleyError) + } + +} \ No newline at end of file diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/networking/PlacesNet.kt b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/networking/PlacesNet.kt new file mode 100644 index 0000000..68b61b8 --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/networking/PlacesNet.kt @@ -0,0 +1,45 @@ +package it.danieleverducci.openweddingapp.networking + +import android.content.Context +import com.android.volley.Request +import com.android.volley.RequestQueue +import com.android.volley.VolleyError +import com.android.volley.toolbox.JsonObjectRequest +import com.android.volley.toolbox.Volley +import it.danieleverducci.openweddingapp.Config +import it.danieleverducci.openweddingapp.entities.Location +import org.json.JSONObject + + +class PlacesNet(context: Context) { + companion object { + val TAG = "PlacesNet" + val url = "${Config.API_BASE_URL}${Config.STATIC_ENDPOINT_PLACES}" + } + + val queue: RequestQueue + + init { + queue = Volley.newRequestQueue(context) + } + + open fun getItem(listener: OnItemObtainedListener) { + val request = JsonObjectRequest( + Request.Method.GET, + url, + null, + { + listener.OnItemObtained(it) + }, + { + listener.OnError(it) + }) + queue.add(request) + } + + interface OnItemObtainedListener { + fun OnItemObtained(item: JSONObject) + fun OnError(error: VolleyError) + } + +} \ No newline at end of file diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/networking/PresenceNet.kt b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/networking/PresenceNet.kt new file mode 100644 index 0000000..6c3831a --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/networking/PresenceNet.kt @@ -0,0 +1,26 @@ +package it.danieleverducci.openweddingapp.networking + +import android.content.Context +import it.danieleverducci.openweddingapp.Config +import it.danieleverducci.openweddingapp.entities.Presence +import org.json.JSONObject + +class PresenceNet(context: Context, token: String): AuthenticatedItemNet(context, token) { + companion object { + val TAG = "PresenceNet" + } + + fun postItem(listener: OnItemPostedListener, item: Presence) { + val url = "${Config.API_BASE_URL}${Config.API_ENDPOINT_PRESENCE}" + super.postItem(url, listener, item) + } + + fun getList(listener: OnItemListObtainedListener) { + val url = "${Config.API_BASE_URL}${Config.API_ENDPOINT_PRESENCE}" + super.getList(url, 1, listener) + } + + override fun newItem(jo: JSONObject): Presence { + return Presence(jo) + } +} \ No newline at end of file diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/networking/RemoteSettingsNet.kt b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/networking/RemoteSettingsNet.kt new file mode 100644 index 0000000..0c60e4b --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/networking/RemoteSettingsNet.kt @@ -0,0 +1,27 @@ +package it.danieleverducci.openweddingapp.networking + +import android.content.Context +import it.danieleverducci.openweddingapp.Config +import it.danieleverducci.openweddingapp.entities.RemoteSettings +import org.json.JSONObject + +/** + * Obtains the app settings from online static endpoint. + * This is used to enable/disable photo sharing remotely and config notifications. + */ + +class RemoteSettingsNet(context: Context): StaticItemNet(context) { + companion object { + val TAG = "RemoteSettingsNet" + val url = "${Config.API_BASE_URL}${Config.STATIC_ENDPOINT_SETTINGS}" + } + + fun getItem(listener: OnItemObtainedListener) { + super.getItem(url, listener) + } + + override fun newItem(jo: JSONObject): RemoteSettings { + return RemoteSettings(jo) + } + +} \ No newline at end of file diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/networking/StaticItemNet.kt b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/networking/StaticItemNet.kt new file mode 100644 index 0000000..37e6dd5 --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/networking/StaticItemNet.kt @@ -0,0 +1,47 @@ +package it.danieleverducci.openweddingapp.networking + +import android.content.Context +import android.util.Log +import com.android.volley.Request +import com.android.volley.RequestQueue +import com.android.volley.Response +import com.android.volley.VolleyError +import com.android.volley.toolbox.JsonObjectRequest +import com.android.volley.toolbox.Volley +import it.danieleverducci.openweddingapp.entities.Jsonable +import org.json.JSONObject + +open abstract class StaticItemNet (val context: Context) { + companion object { + val TAG = "AuthenticatedItemNet" + } + + val queue: RequestQueue + + init { + queue = Volley.newRequestQueue(context) + } + + abstract fun newItem(jo: JSONObject): T + + open fun getItem(url: String, listener: OnItemObtainedListener) { + val request = JsonObjectRequest( + Request.Method.GET, + url, + null, + { + val item = newItem(it) + listener.OnItemObtained(item) + }, + { + listener.OnError(it) + }) + queue.add(request) + } + + interface OnItemObtainedListener { + fun OnItemObtained(item: T) + fun OnError(error: VolleyError) + } + +} \ No newline at end of file diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/networking/TableNet.kt b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/networking/TableNet.kt new file mode 100644 index 0000000..ec6d01b --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/networking/TableNet.kt @@ -0,0 +1,21 @@ +package it.danieleverducci.openweddingapp.networking + +import android.content.Context +import it.danieleverducci.openweddingapp.Config +import it.danieleverducci.openweddingapp.entities.Table +import org.json.JSONObject + +class TableNet(context: Context, token: String): AuthenticatedItemNet(context, token) { + companion object { + val TAG = "TableNet" + } + + fun getItem(listener: OnItemObtainedListener
) { + val url = "${Config.API_BASE_URL}${Config.API_ENDPOINT_TABLE}" + super.getItem(url, 0, listener) + } + + override fun newItem(jo: JSONObject): Table { + return Table(jo) + } +} \ No newline at end of file diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/networking/TokenNet.kt b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/networking/TokenNet.kt new file mode 100644 index 0000000..3508fb1 --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/networking/TokenNet.kt @@ -0,0 +1,69 @@ +package it.danieleverducci.openweddingapp.networking + +import android.content.Context +import android.os.Handler +import android.util.Log +import com.android.volley.DefaultRetryPolicy +import com.android.volley.Request +import com.android.volley.RequestQueue +import com.android.volley.Response +import com.android.volley.toolbox.JsonObjectRequest +import com.android.volley.toolbox.Volley +import it.danieleverducci.openweddingapp.Config +import it.danieleverducci.openweddingapp.entities.GalleryItem +import it.danieleverducci.openweddingapp.entities.Token +import org.json.JSONObject +import java.io.File + + +class TokenNet(val context: Context) { + companion object { + val TAG = "TokenNet" + val TIMEOUT = 10*1000 + } + + enum class Error { + USER_NOT_FOUND, OTHER + } + + val queue: RequestQueue + + init { + queue = Volley.newRequestQueue(context) + } + + fun postItem(listener: OnTokenPostCompletedListener, code: String) { + val jo = JSONObject() + jo.put("code", code) + val request = JsonObjectRequest( + Request.Method.POST, + "${Config.API_BASE_URL}${Config.API_ENDPOINT_TOKEN}/create.php", + jo, + Response.Listener { + listener.OnTokenPostCompleted(Token(it), null) + }, + Response.ErrorListener { + Log.e(TAG, "Unable to obtain token: ${it.message}") + if (it.networkResponse?.statusCode == 404) { + listener.OnTokenPostCompleted(null, Error.USER_NOT_FOUND) + } else { + listener.OnTokenPostCompleted(null, Error.OTHER) + } + } + ) + // Change default timeout to 10s, as the login has an anti-bruteforce delay of 5 secs + request.setRetryPolicy( + DefaultRetryPolicy( + TIMEOUT, + DefaultRetryPolicy.DEFAULT_MAX_RETRIES, + DefaultRetryPolicy.DEFAULT_BACKOFF_MULT) + ) + // Enqueue request + queue.add(request) + } + + interface OnTokenPostCompletedListener { + fun OnTokenPostCompleted(item: Token?, error: Error?) + } + +} \ No newline at end of file diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/networking/UploadItemAsyncTask.kt b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/networking/UploadItemAsyncTask.kt new file mode 100644 index 0000000..c61a0dc --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/networking/UploadItemAsyncTask.kt @@ -0,0 +1,66 @@ +package it.danieleverducci.openweddingapp.networking + +import android.os.AsyncTask +import android.util.Log +import it.danieleverducci.openweddingapp.Config +import it.danieleverducci.openweddingapp.entities.GalleryItem +import it.danieleverducci.openweddingapp.utils.ImageUtils +import org.json.JSONObject +import java.io.File +import java.io.IOException + +class UploadItemAsyncTask(val token: String, val listener: GalleryItemNet.OnGalleryItemUploadCompletedListener): AsyncTask() { + companion object { + val TAG = "UploadItemAsyncTask" + } + + + override fun doInBackground(vararg p0: File?): JSONObject? { + if (p0.size == 0 || p0[0] == null) { + Log.e(TAG, "No image file received") + return JSONObject() + } + + val file = p0[0]!! + + // Resize image to meet maximum upload size + if (file.length() >= Config.MAX_UPLOAD_IMAGE_SIZE) + ImageUtils.resizeImage(file) + + + // Send image to server + val multipart = + AuthenticatedMultipartRequest( + token, + "${Config.API_BASE_URL}${Config.API_ENDPOINT_GALLERY_ITEM}/upload.php", + "UTF-8" + ) + multipart.addFilePart("image", file) + + try { + val response = multipart.finish() + for (line in response) { + return JSONObject(line) + } + return JSONObject("{}") + } catch (e: IOException) { + Log.e(TAG, "Unable to send image to server: " + e.toString()) + return null + } + } + + override fun onPostExecute(result: JSONObject?) { + super.onPostExecute(result) + + if (result != null && result.has("success")) { + val itemJson = result.optJSONObject("record") + var galleryItem: GalleryItem? = null + if (itemJson != null) + galleryItem = GalleryItem(itemJson) + listener.OnGalleryItemUploaded(galleryItem, result.getString("errorMessage")) + } else { + listener.OnGalleryItemUploaded(null, "Server error") + } + } + +} \ No newline at end of file diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/ui/GenericLocalizedContentFragment.kt b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/ui/GenericLocalizedContentFragment.kt new file mode 100644 index 0000000..c90833c --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/ui/GenericLocalizedContentFragment.kt @@ -0,0 +1,122 @@ +package it.danieleverducci.openweddingapp.ui + +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.text.HtmlCompat +import androidx.fragment.app.Fragment +import com.android.volley.VolleyError +import com.squareup.picasso.Picasso +import it.danieleverducci.openweddingapp.Config +import it.danieleverducci.openweddingapp.R +import it.danieleverducci.openweddingapp.databinding.FragmentGenericDynamicContentBinding +import it.danieleverducci.openweddingapp.entities.LocalizedGenericDynamicContent +import it.danieleverducci.openweddingapp.entities.GenericDynamicContent +import it.danieleverducci.openweddingapp.networking.GenericDynamicContentNet +import it.danieleverducci.openweddingapp.networking.StaticItemNet +import it.danieleverducci.openweddingapp.utils.SharedPreferencesManager +import java.lang.RuntimeException +import java.util.* + +abstract class GenericLocalizedContentFragment : Fragment() { + private lateinit var binding: FragmentGenericDynamicContentBinding + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentGenericDynamicContentBinding.inflate(inflater, container, false) + loadContent() + return binding.getRoot() + } + + /** + * Display generic content + */ + open fun fillPageWith(loc: GenericDynamicContent) { + val wg: LocalizedGenericDynamicContent + if (loc.localized.containsKey(Locale.getDefault().language)) + wg = loc.localized.get(Locale.getDefault().language)!! + else if (loc.localized.containsKey(Config.STATIC_CONTENT_DEFAULT_LOCALE)) + wg = loc.localized.get(Config.STATIC_CONTENT_DEFAULT_LOCALE)!! + else + throw RuntimeException("Unsupported locale") + + // Image + Picasso.get() + .load(wg.pictureUrl) + .into(binding.image) + + // Text + binding.name.text = wg.name + binding.content.text = HtmlCompat.fromHtml(wg.content, HtmlCompat.FROM_HTML_MODE_LEGACY) + } + + /** + * Loads generic content from shared preferences, if any. + * Then, tries to update the generic content from static online service. + * To be implemented by extender class + */ + fun loadContent() { + // Load from cache + val gdc = SharedPreferencesManager.loadGenericDynamicContent(requireContext(), getSharedPreferencesKey(), getDefaultRawRes()) + fillPageWith(gdc) + + // Load/update from net + GenericDynamicContentNet(requireContext()).getItem(getEndpoint(), object: StaticItemNet.OnItemObtainedListener { + override fun OnItemObtained(item: GenericDynamicContent) { + if(item.version > gdc.version) { + fillPageWith(item) + SharedPreferencesManager.saveGenericDynamicContent(requireContext(), item, getSharedPreferencesKey()) + Log.d(getLogTag(), "Updated ${getName()}: local ${gdc.version}, remote ${item.version}") + } else { + Log.d(getLogTag(), "${getName()} up to date: local ${gdc.version}, remote ${item.version}") + } + } + + override fun OnError(error: VolleyError) { + Log.d(getLogTag(), "Unable to update ${getName()}: ${error}") + } + }) + } + + /** + * Appends a view at bottom. + */ + fun appendView(v: View) { + binding.container.addView(v) + } + + /** + * Returns the shared preferences key used to cache the element + */ + fun getSharedPreferencesKey(): String { + return getName() + } + + /** + * Returns the shared preferences key used to cache the element + */ + fun getLogTag(): String { + return "${getName()}Fragment" + } + + /** + * Returns the object name. Used to determine the cache key, the log tag etc... + */ + abstract fun getName(): String + + /** + * Returns the json endpoint (relative) + */ + abstract fun getEndpoint(): String + + /** + * Returns the raw resource containing the default json + */ + abstract fun getDefaultRawRes(): Int + +} \ No newline at end of file diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/ui/LoginActivity.kt b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/ui/LoginActivity.kt new file mode 100644 index 0000000..e16f5d7 --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/ui/LoginActivity.kt @@ -0,0 +1,167 @@ +package it.danieleverducci.openweddingapp.ui + +import android.Manifest +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import com.google.zxing.integration.android.IntentIntegrator +import it.danieleverducci.openweddingapp.R +import com.google.android.material.snackbar.Snackbar +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.util.Log +import android.view.View +import android.widget.EditText +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import it.danieleverducci.openweddingapp.DeRApplication +import it.danieleverducci.openweddingapp.MainActivity +import it.danieleverducci.openweddingapp.entities.Token +import it.danieleverducci.openweddingapp.networking.TokenNet +import it.danieleverducci.openweddingapp.utils.WifiUtils +import org.json.JSONException + + +class LoginActivity : AppCompatActivity() { + companion object { + const val MY_PERMISSIONS_REQUEST = 34987 + const val TAG = "LoginActivity" + const val CODE_PARAM_IN_URI = "qr" // i.e. https://mysite.com/?qr=1234 + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_login) + + if ((application as DeRApplication).token != null) { + // User already logged in + val i = Intent(this, MainActivity::class.java) + startActivity(i) + finish() + } else if (intent.data != null) { + // Non-logged user started the application from a link + loginWithMagicLink(intent.data) + + } + } + + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + + // Non-logged user brings on top the already-running application from a link + loginWithMagicLink(intent?.data) + } + + private fun loginWithMagicLink(uri: Uri?) { + if (uri == null) { + Log.e(TAG,"Intent with no URI received!") + return + } + + val code = uri.getQueryParameter(CODE_PARAM_IN_URI) + if (code != null) + checkUserCredentials(code) + else + setContentView(R.layout.activity_login) + } + + public fun onManualLoginButtonClicked(v: View) { + val codeField = findViewById(R.id.login_code) + val code = codeField.text.toString() + checkUserCredentials(code) + } + + public fun onLoginButtonClicked(v: View) { + // Request camera permission + if (ContextCompat.checkSelfPermission(this, + Manifest.permission.CAMERA) + != PackageManager.PERMISSION_GRANTED) { + // Request permission + ActivityCompat.requestPermissions(this, + arrayOf(Manifest.permission.CAMERA), + MY_PERMISSIONS_REQUEST) + } else { + // Permission has already been granted: Start qrcode scanner + IntentIntegrator(this).initiateScan() + } + + } + + private fun checkUserCredentials(code: String) { + findViewById(R.id.loading_screen).visibility = View.VISIBLE + try { + val tokenNet = TokenNet(this) + tokenNet.postItem( + object: TokenNet.OnTokenPostCompletedListener { + override fun OnTokenPostCompleted(item: Token?, error: TokenNet.Error?) { + findViewById(R.id.loading_screen).visibility = View.GONE + if (error == null && item?.token != null) { + // Save user + (application as DeRApplication).token = item + // Start main activity + val i = Intent(applicationContext, MainActivity::class.java) + startActivity(i) + finish() + } else { + if (error == TokenNet.Error.USER_NOT_FOUND) { + Snackbar.make(findViewById(R.id.activity_login_root), R.string.login_net_notfound_error, Snackbar.LENGTH_LONG).show() + } else { + // No connection? + + Snackbar.make(findViewById(R.id.activity_login_root), R.string.login_net_generic_error, Snackbar.LENGTH_LONG).show() + // Try to connect to wedding network + val app = (application as DeRApplication) + if (!app.askedConnectingToWeddingWifi) { + WifiUtils.askToAddWeddingNetworks(this@LoginActivity, app.remoteSettings.wifiNetworks) + app.askedConnectingToWeddingWifi = true + } + } + } + } + }, + code + ) + } catch (e: JSONException) { + Log.d(TAG, "Unable to parse json from server: ${e.toString()}") + Snackbar.make(findViewById(R.id.activity_login_root), R.string.login_server_error, Snackbar.LENGTH_LONG).show() + } + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + // QR Code scanned + val result = IntentIntegrator.parseActivityResult(requestCode, resultCode, data) + if (result != null) { + if (result.contents != null) { + Log.d(TAG, "Qrcode is " + resultCode); + try { + // Obtain code from URL (http://some/url?qr=125863) + val url = result.contents + val uri = Uri.parse(url) + val code = uri.getQueryParameter("qr") + ?: throw IllegalArgumentException("No code found") + checkUserCredentials(code); + } catch (e: java.lang.IllegalArgumentException) { + Snackbar.make(findViewById(R.id.activity_login_root), R.string.login_qr_unrecognized_error, Snackbar.LENGTH_LONG).show() + } + } + } else { + super.onActivityResult(requestCode, resultCode, data) + } + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + when (requestCode) { + MY_PERMISSIONS_REQUEST -> { + // If request is cancelled, the result arrays are empty. + if ((grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED)) { + // permission was granted + IntentIntegrator(this).initiateScan() + } + return + } + } + } + + + +} \ No newline at end of file diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/ui/StaticFragment.kt b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/ui/StaticFragment.kt new file mode 100644 index 0000000..83befd4 --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/ui/StaticFragment.kt @@ -0,0 +1,23 @@ +package it.danieleverducci.openweddingapp.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment + +/** + * A fragment to display static content + */ +abstract class StaticFragment: Fragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return inflater.inflate(getLayoutResource(), container, false) + } + + protected abstract fun getLayoutResource(): Int +} \ No newline at end of file diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/ui/attendeegift/AttendeeGiftFragment.kt b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/ui/attendeegift/AttendeeGiftFragment.kt new file mode 100644 index 0000000..44cceb6 --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/ui/attendeegift/AttendeeGiftFragment.kt @@ -0,0 +1,22 @@ +package it.danieleverducci.openweddingapp.ui.attendeegift + +import it.danieleverducci.openweddingapp.Config +import it.danieleverducci.openweddingapp.R +import it.danieleverducci.openweddingapp.ui.GenericLocalizedContentFragment + + +class AttendeeGiftFragment: GenericLocalizedContentFragment() { + + override fun getName(): String { + return "AttendeeGift" + } + + override fun getEndpoint(): String { + return Config.STATIC_ENDPOINT_ATTENDEE_GIFT + } + + override fun getDefaultRawRes(): Int { + return R.raw.default_json_ag + } + +} \ No newline at end of file diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/ui/foodmenu/FoodMenuFragment.kt b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/ui/foodmenu/FoodMenuFragment.kt new file mode 100644 index 0000000..2d47e17 --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/ui/foodmenu/FoodMenuFragment.kt @@ -0,0 +1,21 @@ +package it.danieleverducci.openweddingapp.ui.foodmenu + +import it.danieleverducci.openweddingapp.Config +import it.danieleverducci.openweddingapp.R +import it.danieleverducci.openweddingapp.ui.GenericLocalizedContentFragment + +class FoodMenuFragment : GenericLocalizedContentFragment() { + + override fun getName(): String { + return "FoodMenu" + } + + override fun getEndpoint(): String { + return Config.STATIC_ENDPOINT_FOOD_MENU + } + + override fun getDefaultRawRes(): Int { + return R.raw.default_json_fm + } + +} \ No newline at end of file diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/ui/gallery/GalleryFragment.kt b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/ui/gallery/GalleryFragment.kt new file mode 100644 index 0000000..2cebd80 --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/ui/gallery/GalleryFragment.kt @@ -0,0 +1,279 @@ +package it.danieleverducci.openweddingapp.ui.gallery + +import android.content.Intent +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.animation.AnimationUtils +import android.widget.Toast +import com.google.android.material.snackbar.Snackbar +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import com.android.volley.AuthFailureError +import com.android.volley.VolleyError +import it.danieleverducci.openweddingapp.DeRApplication +import it.danieleverducci.openweddingapp.MainActivity +import it.danieleverducci.openweddingapp.R +import it.danieleverducci.openweddingapp.entities.GalleryItem +import it.danieleverducci.openweddingapp.entities.Like +import it.danieleverducci.openweddingapp.networking.AuthenticatedItemNet +import it.danieleverducci.openweddingapp.networking.GalleryItemNet +import it.danieleverducci.openweddingapp.networking.LikeNet +import it.danieleverducci.openweddingapp.utils.SharedPreferencesManager +import java.io.File + +class GalleryFragment : Fragment(), GalleryRecyclerAdapter.GalleryItemListener, + SwipeRefreshLayout.OnRefreshListener { + + companion object { + val TAG = "GalleryFragment" + } + + private val galleryStream = ArrayList() + private lateinit var grAdapter: GalleryRecyclerAdapter + private lateinit var swipeRefreshLayout: SwipeRefreshLayout + private lateinit var galleryItemNet: GalleryItemNet + private lateinit var recyclerView: RecyclerView + private lateinit var uploadingBanner: View + private var moreItemsAvailable = true + private var nextPage = 0 + private val checkForNewPostsHandler = Handler(Looper.getMainLooper()) + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + val root = inflater.inflate(R.layout.fragment_gallery, container, false) + val token = (activity!!.applicationContext as DeRApplication).token + if (token == null) { + (activity as MainActivity).logout() + } else { + galleryItemNet = GalleryItemNet(requireContext(), token.token) + uploadingBanner = root.findViewById(R.id.gallery_uploading) + recyclerView = root.findViewById(R.id.gallery_recyclerview) + swipeRefreshLayout = root.findViewById(R.id.gallery_swiperefresh) + swipeRefreshLayout.setOnRefreshListener(this) + + loadItems(nextPage) + + grAdapter = GalleryRecyclerAdapter(activity!!, galleryStream) + grAdapter.onGalleryItemListener = this + + recyclerView.apply { + setHasFixedSize(true) + layoutManager = LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false) + adapter = grAdapter + } + } + + return root + } + + override fun onStart() { + super.onStart() + + (activity as MainActivity).showFab(true) + + // Start timer: checks every 2 secs for new posts + checkForNewPosts() + } + + override fun onStop() { + (activity as MainActivity).showFab(false) + + // Stop timer + checkForNewPostsHandler.removeCallbacksAndMessages(null) + + super.onStop() + } + + override fun OnGalleryItemClickedListener(item: GalleryItem) { + val intent = Intent(context, GalleryFullscreenViewerActivity::class.java) + intent.putExtra(GalleryFullscreenViewerActivity.EXTRAS_IMGURL, item.url) + startActivity(intent) + } + + override fun OnGalleryItemLikedListener(item: GalleryItem) { + if (context == null) + return + + val app = activity!!.applicationContext as DeRApplication + val ln = LikeNet(requireContext(), app.token!!.token) + if (item.currentUserLike == null) { + // Add like + val like = Like(item.id, app.token!!.userId) + ln.postItem( + object : AuthenticatedItemNet.OnItemPostedListener { + override fun OnItemPosted(l: Like) { + item.currentUserLike = l + grAdapter.notifyDataSetChanged() + } + + override fun OnError(error: VolleyError) { + Log.e(TAG, error.message ?: "") + Toast.makeText(context, R.string.gallery_like_error, Toast.LENGTH_SHORT) + .show() + } + }, + like + ) + } else { + // Remove like + ln.deleteItem( + object: AuthenticatedItemNet.OnItemDeletedListener { + override fun OnItemDeleted() { + item.currentUserLike = null + if (item.firstUserLiked?.id == app.token!!.userId) { + // Disliker's name is shown near the like. We have no data to change this locally, force reload. + onRefresh(); + } + + grAdapter.notifyDataSetChanged() + } + + override fun OnError(error: VolleyError) { + Log.e(TAG, error.message ?: "") + Toast.makeText(context, R.string.gallery_dislike_error, Toast.LENGTH_SHORT) + .show() + } + }, + item.currentUserLike!!.id + ) + } + } + + override fun OnGalleryLastItemScrolledListener() { + loadItems(nextPage) + } + + public fun onPhotoTaken(gi: GalleryItem) { + + val file = File(gi.imageUrl) + if (file.exists()) { + val token = (activity?.applicationContext as DeRApplication).token?.token + if (token == null) { + Log.e(TAG, "Unable to post photo: no token!") + return + } + setUploading(true) + galleryItemNet.uploadItem( + token, + file, + object : GalleryItemNet.OnGalleryItemUploadCompletedListener { + override fun OnGalleryItemUploaded(uploadedGi: GalleryItem?, error: String) { + setUploading(false) + if (!error.isEmpty()) { + Snackbar.make(view!!, "${getString(R.string.upload_galleryitem_error)}: ${error}", Snackbar.LENGTH_SHORT).show() + return + } + if (uploadedGi != null) { + galleryStream.add(0, uploadedGi) + grAdapter.notifyDataSetChanged() + recyclerView.smoothScrollToPosition(0) + } else { + Snackbar.make(view!!, getString(R.string.upload_galleryitem_error), Snackbar.LENGTH_SHORT).show() + } + } + }) + } else { + Log.e(TAG, "Unable to find file ${file.path} for upload") + } + } + + override fun onRefresh() { + moreItemsAvailable = true + nextPage = 0 + loadItems(nextPage) + } + + private fun loadItems(page: Int) { + if (!moreItemsAvailable) { + return + } + + galleryItemNet.getList(page, object: AuthenticatedItemNet.OnItemListObtainedListener { + + override fun OnItemListObtained( + items: ArrayList?, + page: Int, + more: Boolean + ) { + if (items == null) { + Log.e(TAG, "Gallery request successful but no items received!") + return + } + + if (page == 0) { + galleryStream.clear() + swipeRefreshLayout.isRefreshing = false + + // Save for cache + if (context != null) + SharedPreferencesManager.saveGalleryCache(requireContext(), items) + } + galleryStream.addAll(items) + grAdapter.notifyDataSetChanged() + + moreItemsAvailable = more + grAdapter.moreItemsAvailable = moreItemsAvailable + if (moreItemsAvailable) { + nextPage++ + } + } + + override fun OnError(error: VolleyError) { + if (error is AuthFailureError) { + (activity as MainActivity).logout(); + } else { + Log.e(TAG, "Unable to download gallery list: ${error.toString()}") + if (view != null) { + Snackbar.make( + view!!, + R.string.gallery_network_error, + Snackbar.LENGTH_SHORT + ).show() + } + + if (page == 0 && context != null) { + // Show cached content + val cachedGi = SharedPreferencesManager.loadGalleryCache(requireContext()) + if (cachedGi != null) { + galleryStream.clear() + swipeRefreshLayout.isRefreshing = false + galleryStream.addAll(cachedGi) + grAdapter.notifyDataSetChanged() + moreItemsAvailable = false + grAdapter.moreItemsAvailable = moreItemsAvailable + } + } + } + } + + }) + } + + fun setUploading(uploading: Boolean) { + uploadingBanner.visibility = if (uploading) View.VISIBLE else View.GONE + } + + /** + * Checks for new posts every 2 seconds. + * Started on onStart and canceled on onStop + */ + private fun checkForNewPosts() { + // If list is scrolled all the way up, load new items + if ((this.recyclerView.layoutManager as LinearLayoutManager).findFirstCompletelyVisibleItemPosition() == 0) + onRefresh() + + checkForNewPostsHandler.postDelayed({ + checkForNewPosts() + }, 5000) + } +} \ No newline at end of file diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/ui/gallery/GalleryFullscreenViewerActivity.kt b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/ui/gallery/GalleryFullscreenViewerActivity.kt new file mode 100644 index 0000000..798c1b7 --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/ui/gallery/GalleryFullscreenViewerActivity.kt @@ -0,0 +1,48 @@ +package it.danieleverducci.openweddingapp.ui.gallery + +import android.os.Bundle +import android.os.PersistableBundle +import android.view.View +import androidx.appcompat.app.AppCompatActivity +import com.github.chrisbanes.photoview.PhotoView +import com.squareup.picasso.Picasso +import it.danieleverducci.openweddingapp.R +import java.io.File +import java.security.InvalidParameterException + +class GalleryFullscreenViewerActivity: AppCompatActivity() { + companion object { + val EXTRAS_IMGURL = "imgurl" + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_galleryfullscreenviewer) + + val imgUrl = intent.extras?.getString(EXTRAS_IMGURL) + if (imgUrl == null) { + throw InvalidParameterException("No image url specified") + } + + val imageView = findViewById(R.id.image) + imageView.systemUiVisibility = + View.SYSTEM_UI_FLAG_IMMERSIVE or + View.SYSTEM_UI_FLAG_FULLSCREEN or + View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + if (imgUrl.startsWith("http")) { + Picasso.get() + .load(imgUrl) + .error(R.drawable.placeholder) + .into(imageView); + } else { + Picasso.get() + .load(File(imgUrl)) + .error(R.drawable.placeholder) + .into(imageView); + } + + findViewById(R.id.close_button).setOnClickListener { + finish() + } + } +} \ No newline at end of file diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/ui/gallery/GalleryRecyclerAdapter.kt b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/ui/gallery/GalleryRecyclerAdapter.kt new file mode 100644 index 0000000..3c1d634 --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/ui/gallery/GalleryRecyclerAdapter.kt @@ -0,0 +1,249 @@ +package it.danieleverducci.openweddingapp.ui.gallery + +import android.Manifest +import android.app.Activity +import android.app.DownloadManager +import android.app.Service +import android.content.Context +import android.content.Context.DOWNLOAD_SERVICE +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Environment +import android.os.Handler +import android.os.Looper +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.animation.Animation +import android.view.animation.AnimationUtils +import android.widget.* +import androidx.appcompat.widget.AppCompatImageView +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.core.content.ContextCompat.getSystemService +import androidx.recyclerview.widget.RecyclerView +import com.squareup.picasso.Picasso +import it.danieleverducci.openweddingapp.Config +import it.danieleverducci.openweddingapp.MainActivity +import it.danieleverducci.openweddingapp.R +import it.danieleverducci.openweddingapp.entities.GalleryItem +import it.danieleverducci.openweddingapp.utils.ImageUtils +import it.danieleverducci.openweddingapp.utils.ViewUtils + + +class GalleryRecyclerAdapter(private val activityContext: Activity, private val dataset: ArrayList): + RecyclerView.Adapter() { + companion object { + val TAG = "GalleryRecyclerAdapter" + } + + public var onGalleryItemListener: GalleryItemListener? = null + public var moreItemsAvailable = true + private lateinit var likeButtonAnimation: Animation + private var sharing = false + + + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + if (!this::likeButtonAnimation.isInitialized) + likeButtonAnimation = AnimationUtils.loadAnimation(parent.context, R.anim.like_button) + + val view = LayoutInflater.from(parent.context).inflate(R.layout.listitem_gallery, parent, false) as LinearLayout + view.setOnClickListener(object: View.OnClickListener{ + override fun onClick(v: View?) { + if (v != null) { + onGalleryItemListener?.OnGalleryItemClickedListener(v.getTag() as GalleryItem) + } + } + }) + val likeButton = view.findViewById(R.id.gallery_listitem_like_button) + likeButton.setOnClickListener(object: View.OnClickListener{ + override fun onClick(v: View?) { + if (v != null) { + // Play animation + likeButtonAnimation.setAnimationListener(object : Animation.AnimationListener { + override fun onAnimationStart(p0: Animation?) {} + + override fun onAnimationEnd(p0: Animation?) { + // Notify listener + val rootLayout = v.parent.parent.parent as View + onGalleryItemListener?.OnGalleryItemLikedListener(rootLayout.getTag() as GalleryItem) + } + + override fun onAnimationRepeat(p0: Animation?) {} + + }) + likeButton.startAnimation(likeButtonAnimation) + } + } + }) + + // Download button + val downloadButton = view.findViewById(R.id.gallery_listitem_download_button) + downloadButton.setOnClickListener(object: View.OnClickListener{ + override fun onClick(v: View?) { + if (v != null) { + // Request storage permission + if (ContextCompat.checkSelfPermission(v.context, + Manifest.permission.WRITE_EXTERNAL_STORAGE) + != PackageManager.PERMISSION_GRANTED) { + // Request permission + ActivityCompat.requestPermissions(activityContext, + arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), + MainActivity.MY_PERMISSIONS_REQUEST + ) + } else { + val rootLayout = v.parent.parent.parent as View + val uri = + Uri.parse("${Config.MEDIA_BASE_URL}${(rootLayout.getTag() as GalleryItem).imageUrl}") + val r = DownloadManager.Request(uri) + r.setDestinationInExternalPublicDir( + Environment.DIRECTORY_PICTURES, + "${Config.SAVED_FILES_BASE_NAME}-${System.currentTimeMillis()}.jpg" + ) + r.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) + val dm = v.context.getSystemService(DOWNLOAD_SERVICE) as DownloadManager? + dm!!.enqueue(r) + + val progressBar = rootLayout.findViewById(R.id.gallery_listitem_share_progressbar) + // Replace button with spinning indicator + progressBar.visibility = View.VISIBLE + v.visibility = View.GONE + // After 1 sec, back to previous + val h = Handler(Looper.getMainLooper()) + h.postDelayed({ + // Replace button with spinning indicator + progressBar.visibility = View.GONE + v.visibility = View.VISIBLE + Toast.makeText(v.context, R.string.image_download_started, Toast.LENGTH_SHORT).show() + }, 1000) + } + } + } + }) + + // Share button + val shareButton = view.findViewById(R.id.gallery_listitem_share_button) + shareButton.setOnClickListener(object: View.OnClickListener{ + override fun onClick(v: View?) { + if (v != null) { + if (sharing) { + Toast.makeText(v.context, R.string.image_share_already_sharing, Toast.LENGTH_SHORT).show() + return + } + + sharing = true + val rootLayout = v.parent.parent.parent as View + val progressBar = rootLayout.findViewById(R.id.gallery_listitem_share_progressbar) + // Replace button with spinning indicator + progressBar.visibility = View.VISIBLE + v.visibility = View.GONE + ImageUtils.shareImage(v.context, "${Config.MEDIA_BASE_URL}${(rootLayout.getTag() as GalleryItem).imageUrl}", + object: ImageUtils.Companion.OnImageSharedListener { + override fun onImageShared() { + // Replace spinning indicator with button + progressBar.visibility = View.GONE + v.visibility = View.VISIBLE + sharing = false + } + + override fun onImageShareFailed() { + progressBar.visibility = View.GONE + Toast.makeText(v.context, R.string.image_share_fail, Toast.LENGTH_SHORT).show() + sharing = false + } + }) + } + } + }) + return ViewHolder(view) + } + + override fun getItemCount(): Int { + return dataset.size + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + if (position == itemCount - 1) { + onGalleryItemListener?.OnGalleryLastItemScrolledListener() + } + holder.setItem(dataset.get(position), position == itemCount-1, moreItemsAvailable) + } + + class ViewHolder(val view: LinearLayout): RecyclerView.ViewHolder(view) { + val userImageBadge: AppCompatImageView + val userTextBadge: TextView + val userName: TextView + val image: ImageView + val description: TextView + val likes: TextView + val likeButton: ImageView + val progress: View + init { + userImageBadge = view.findViewById(R.id.gallery_listitem_user_badge) + userTextBadge = view.findViewById(R.id.gallery_listitem_user_text_badge) + userName = view.findViewById(R.id.gallery_listitem_user_name) + image = view.findViewById(R.id.gallery_listitem_image) + description = view.findViewById(R.id.gallery_listitem_description) + likes = view.findViewById(R.id.gallery_listitem_likes) + likeButton = view.findViewById(R.id.gallery_listitem_like_button) + progress = view.findViewById(R.id.gallery_listitem_progress) + } + + public fun setItem(item: GalleryItem, isLast: Boolean, more: Boolean) { + view.setTag(item) + + // Post image + Picasso.get() + .load(item.thumbUrl) + .placeholder(R.drawable.placeholder) + .error(R.drawable.placeholder) + .into(image) + + // User image + if (item.author.picture != null && item.author.picture.isNotEmpty()) { + userImageBadge.visibility = View.VISIBLE + userTextBadge.visibility = View.GONE + Picasso.get() + .load(item.author.pictureUrl) + .placeholder(R.drawable.placeholder) + .error(R.drawable.placeholder) + .into(userImageBadge) + } else { + userImageBadge.visibility = View.GONE + userTextBadge.visibility = View.VISIBLE + userTextBadge.text = item.author.name.substring(0,1) + ViewUtils.colorizeUserBadge(userTextBadge, item.author) + } + + userName.setText("${item.author.name} ${item.author.surname}") + if (item.description.isNotEmpty()) { + description.visibility = View.VISIBLE + description.text = item.description + } else { + description.visibility = View.GONE + } + + // Set likes text + if (item.likes == 0) + likes.setText("") + else if (item.likes == 1) + likes.setText(likes.context.getString(R.string.gallery_liked_single).format(item.firstUserLiked?.name)) + else + likes.setText(likes.context.getString(R.string.gallery_liked_by).format(item.firstUserLiked?.name, item.likes - 1)) + + // Set icon color + likeButton.background = ContextCompat.getDrawable(likeButton.context, + if (item.currentUserLike != null) R.drawable.gallery_like_button_background_active else R.drawable.gallery_like_button_background_inactive + ) + + progress.visibility = if (isLast && more) View.VISIBLE else View.GONE + } + } + + interface GalleryItemListener { + fun OnGalleryItemClickedListener(item: GalleryItem) + fun OnGalleryItemLikedListener(item: GalleryItem) + fun OnGalleryLastItemScrolledListener() + } +} \ No newline at end of file diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/ui/info/InfoFragment.kt b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/ui/info/InfoFragment.kt new file mode 100644 index 0000000..f612cd7 --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/ui/info/InfoFragment.kt @@ -0,0 +1,12 @@ +package it.danieleverducci.openweddingapp.ui.info + +import it.danieleverducci.openweddingapp.R +import it.danieleverducci.openweddingapp.ui.StaticFragment + +class InfoFragment : StaticFragment() { + + override fun getLayoutResource(): Int { + return R.layout.fragment_info + } + +} \ No newline at end of file diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/ui/info/PrivacyFragment.kt b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/ui/info/PrivacyFragment.kt new file mode 100644 index 0000000..169ef46 --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/ui/info/PrivacyFragment.kt @@ -0,0 +1,24 @@ +package it.danieleverducci.openweddingapp.ui.info + +import android.service.autofill.TextValueSanitizer +import android.widget.TextView +import it.danieleverducci.openweddingapp.R +import it.danieleverducci.openweddingapp.ui.StaticFragment + +class PrivacyFragment: StaticFragment() { + + override fun getLayoutResource(): Int { + return R.layout.fragment_privacy + } + + override fun onResume() { + super.onResume() + + // Load privacy agreement from resources + view?.findViewById(R.id.privacy_content)?.text = + context?.resources?.openRawResource(R.raw.privacy)?.bufferedReader().use { + it?.readText() + } + } + +} \ No newline at end of file diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/ui/location/LocationFragment.kt b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/ui/location/LocationFragment.kt new file mode 100644 index 0000000..579c277 --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/ui/location/LocationFragment.kt @@ -0,0 +1,142 @@ +package it.danieleverducci.openweddingapp.ui.location + +import android.content.Intent +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.content.res.AppCompatResources +import androidx.core.content.ContextCompat +import androidx.core.text.HtmlCompat +import androidx.fragment.app.Fragment +import androidx.preference.PreferenceManager +import com.android.volley.VolleyError +import com.squareup.picasso.Picasso +import it.danieleverducci.openweddingapp.BuildConfig +import it.danieleverducci.openweddingapp.Config +import it.danieleverducci.openweddingapp.R +import it.danieleverducci.openweddingapp.databinding.FragmentLocationBinding +import it.danieleverducci.openweddingapp.entities.LocalizedLocation +import it.danieleverducci.openweddingapp.entities.Location +import it.danieleverducci.openweddingapp.networking.LocationNet +import it.danieleverducci.openweddingapp.networking.StaticItemNet +import it.danieleverducci.openweddingapp.utils.PackageUtils +import it.danieleverducci.openweddingapp.utils.SharedPreferencesManager +import org.osmdroid.config.Configuration +import org.osmdroid.util.GeoPoint +import org.osmdroid.views.overlay.Marker +import java.lang.RuntimeException +import java.util.* + +class LocationFragment : Fragment() { + companion object { + private val TAG = "LocationFragment" + } + private lateinit var binding: FragmentLocationBinding + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentLocationBinding.inflate(inflater, container, false) + + // Setup map + val osmdroidConfig = Configuration.getInstance() + osmdroidConfig.load( + context, + PreferenceManager.getDefaultSharedPreferences(requireContext()) + ) + osmdroidConfig.userAgentValue = BuildConfig.APPLICATION_ID + binding.map.setMultiTouchControls(true) + binding.map.setTilesScaledToDpi(true) + + loadLocation() + + return binding.getRoot() + } + + /** + * Loads location from shared preferences, if any. + * Then, tries to update location from static online service. + */ + fun loadLocation() { + // Load from cache + val location = SharedPreferencesManager.loadLocation(requireContext()) + fillPageWith(location) + + // Load/update from net + LocationNet(requireContext()).getItem(object: StaticItemNet.OnItemObtainedListener { + override fun OnItemObtained(item: Location) { + if(item.version > location.version) { + fillPageWith(item) + binding.locationProgress.visibility = View.GONE + binding.locationContainer.visibility = View.VISIBLE + SharedPreferencesManager.saveLocation(requireContext(), item) + Log.d(TAG, "Updated location: local ${location.version}, remote ${item.version}") + } else { + Log.d(TAG, "Location up to date: local ${location.version}, remote ${item.version}") + } + } + + override fun OnError(error: VolleyError) { + binding.locationProgress.visibility = View.GONE + Log.d(TAG, "Unable to update location: ${error}") + } + }) + } + + fun fillPageWith(loc: Location) { + val location: LocalizedLocation + if (loc.localized.containsKey(Locale.getDefault().language)) + location = loc.localized.get(Locale.getDefault().language)!! + else if (loc.localized.containsKey(Config.STATIC_CONTENT_DEFAULT_LOCALE)) + location = loc.localized.get(Config.STATIC_CONTENT_DEFAULT_LOCALE)!! + else + throw RuntimeException("Missing default locale ${Config.STATIC_CONTENT_DEFAULT_LOCALE} in location json!") + + // Image + Picasso.get() + .load(location.pictureUrl) + .error(R.drawable.placeholder) + .into(binding.image) + + // Text + binding.name.text = location.name + binding.content.text = HtmlCompat.fromHtml(location.content, HtmlCompat.FROM_HTML_MODE_LEGACY) + + // Map + updateViewCoords(location) + + // Navigation button + binding.navigate.setOnClickListener { + // Generate geouri intent + val i = Intent() + i.action = Intent.ACTION_VIEW + i.data = if (PackageUtils.isGoogleMapsInstalled(requireContext())) location.gooleMapsUri else location.geoUri + startActivity(i) + } + } + + fun updateViewCoords(item: LocalizedLocation) { + // Center map + val position = GeoPoint(item.coordinates[0], item.coordinates[1]) + val mapController = binding.map.controller + mapController.setZoom(15.0) + mapController.setCenter(position) + + // Set pin + val mapMarker = Marker(binding.map) + mapMarker.setPosition(position) + val drawable = AppCompatResources.getDrawable( + requireContext(), + R.drawable.ic_map_pin + ) + if (android.os.Build.VERSION.SDK_INT >= 21) + drawable!!.setTint(ContextCompat.getColor(requireContext(), R.color.colorAccent)) + mapMarker.setIcon(drawable) + mapMarker.setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) + binding.map.overlays.add(mapMarker) + } +} \ No newline at end of file diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/ui/places/PlacesFragment.kt b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/ui/places/PlacesFragment.kt new file mode 100644 index 0000000..c7544e8 --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/ui/places/PlacesFragment.kt @@ -0,0 +1,233 @@ +package it.danieleverducci.openweddingapp.ui.places + +import android.Manifest +import android.content.pm.PackageManager +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.content.res.AppCompatResources +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import androidx.preference.PreferenceManager +import com.android.volley.VolleyError +import it.danieleverducci.openweddingapp.BuildConfig +import it.danieleverducci.openweddingapp.Config +import it.danieleverducci.openweddingapp.R +import it.danieleverducci.openweddingapp.databinding.FragmentPlacesBinding +import it.danieleverducci.openweddingapp.entities.MapPlace +import it.danieleverducci.openweddingapp.networking.PlacesNet +import it.danieleverducci.openweddingapp.utils.SharedPreferencesManager +import org.json.JSONObject +import org.osmdroid.config.Configuration +import org.osmdroid.util.GeoPoint +import org.osmdroid.views.overlay.Marker +import org.osmdroid.views.overlay.mylocation.GpsMyLocationProvider +import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay +import java.util.* + + +class PlacesFragment : Fragment() { + companion object { + private val TAG = "PlacesFragment" + private val PERMISSION_REQUEST_CODE = 4262 + } + private lateinit var binding: FragmentPlacesBinding + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentPlacesBinding.inflate(inflater, container, false) + + // If first time + if (!SharedPreferencesManager.hasPlacesHintBeenDisplayed(requireContext())) { + // Show hint + val hintView = binding.placesHint + hintView.visibility = View.VISIBLE + // Allow dismissing by click + hintView.setOnClickListener { v: View? -> v?.visibility = View.GONE } + // Also dismiss after 3 secs just to be sure + Handler(Looper.getMainLooper()).postDelayed({ + hintView.visibility = View.GONE + }, 3000) + } + + // Setup map + val osmdroidConfig = Configuration.getInstance() + osmdroidConfig.load( + requireContext(), + PreferenceManager.getDefaultSharedPreferences(requireContext()) + ) + osmdroidConfig.userAgentValue = BuildConfig.APPLICATION_ID + binding.map.setMultiTouchControls(true) + binding.map.setTilesScaledToDpi(true) + binding.map.maxZoomLevel = 22.0 + + loadPlaces() + + showUserPosition() + + return binding.getRoot() + } + + fun showUserPosition() { + // Check if user granted location permission + if (context != null && ActivityCompat.checkSelfPermission( + requireContext(), + Manifest.permission.ACCESS_FINE_LOCATION + ) != PackageManager.PERMISSION_GRANTED && ActivityCompat.checkSelfPermission( + requireContext(), + Manifest.permission.ACCESS_COARSE_LOCATION + ) != PackageManager.PERMISSION_GRANTED + ) { + // User didn't grant permission. Ask it. + requestPermissions( + arrayOf(Manifest.permission.ACCESS_FINE_LOCATION), + PERMISSION_REQUEST_CODE + ) + return + } + + val mLocationOverlay = MyLocationNewOverlay(GpsMyLocationProvider(context), binding.map) + mLocationOverlay.enableMyLocation() + binding.map.getOverlays().add(mLocationOverlay) + } + + /** + * Loads Places from shared preferences, if any. + * Then, tries to update location from static online service. + */ + private fun loadPlaces() { + val plJo = SharedPreferencesManager.loadPlaces(requireContext()) + clearAndShowMarkers(plJo) + + // Update data + val pn = PlacesNet(requireContext()) + pn.getItem(object: PlacesNet.OnItemObtainedListener { + override fun OnItemObtained(item: JSONObject) { + if (context == null) + return + // Update map + if (item.getInt("version") > plJo.getInt("version")) { + // Save cache + SharedPreferencesManager.savePlaces(requireContext(), item) + clearAndShowMarkers(item) + } + } + + override fun OnError(error: VolleyError) { + Log.e(TAG, "Unable to update places list: $error") + } + + }) + } + + fun clearAndShowMarkers(plJo: JSONObject) { + // Clear map + clearMap() + + // Center map + centerMap(plJo.getDouble("mapCenterLat"), plJo.getDouble("mapCenterLon"), plJo.getDouble("mapZoom")) + + // Show pins + var locale = Locale.getDefault().language + if (!plJo.getJSONObject("localized").has(locale)) + locale = Config.STATIC_CONTENT_DEFAULT_LOCALE + + val localizedMapPositions = plJo.getJSONObject("localized").getJSONArray(locale) + for (i in 0..localizedMapPositions.length() - 1) { + addMapPlace(MapPlace(localizedMapPositions.getJSONObject(i))) + } + } + + fun centerMap(lat: Double, lon: Double, zoom: Double) { + // Center map + val position = GeoPoint(lat, lon) + val mapController = binding.map.controller + mapController.setZoom(zoom) + mapController.setCenter(position) + } + + fun clearMap() { + // Removes all the markers + binding.map.overlays.clear() + } + + fun addMapPlace(item: MapPlace) { + // Set pin + val mapMarker = Marker(binding.map) + mapMarker.setPosition(GeoPoint(item.lat, item.lon)) + val drawable = AppCompatResources.getDrawable( + requireContext(), + getIcon(item) + ) + if (android.os.Build.VERSION.SDK_INT >= 21) + drawable!!.setTint(ContextCompat.getColor(requireContext(), R.color.colorAccent)) + mapMarker.setIcon(drawable) + mapMarker.title = item.name + mapMarker.snippet = item.time + mapMarker.setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER) + mapMarker.setOnMarkerClickListener { marker, mapView -> + // Scroll to position and show details + mapView.controller.animateTo(marker.position) + showPlaceDetails(item) + true + } + binding.map.overlays.add(mapMarker) + } + + private fun showPlaceDetails(item: MapPlace) { + binding.placesInfoPanel.visibility = View.VISIBLE + binding.placesInfoIcon.setImageResource(getIcon(item)) + binding.placesInfoTime.text = item.time + binding.placesInfoTitle.text = item.name + binding.placesInfoDescr.text = item.descr + } + + private fun getIcon(item: MapPlace): Int { + return when (item.type) { + "ceremony" -> R.drawable.ic_map_ceremony + "appetizer" -> R.drawable.ic_map_appetizer + "lunch" -> R.drawable.ic_map_lunch + "mate" -> R.drawable.ic_map_mate + "liquor" -> R.drawable.ic_map_liquor + "cigars" -> R.drawable.ic_map_cigars + "parking" -> R.drawable.ic_map_parking + "ecology" -> R.drawable.ic_map_ecology + "photo" -> R.drawable.ic_map_photo + "book" -> R.drawable.ic_map_book + "games" -> R.drawable.ic_map_games + "etnic" -> R.drawable.ic_map_etnic + else -> R.drawable.ic_map_pin + } + } + + override fun onResume() { + super.onResume() + binding.map.onResume() + } + + override fun onPause() { + binding.map.onPause() + super.onPause() + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == PERMISSION_REQUEST_CODE && permissions[0] == Manifest.permission.ACCESS_FINE_LOCATION) { + if (grantResults[0] == PackageManager.PERMISSION_GRANTED) { + showUserPosition() + } + } + } +} \ No newline at end of file diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/ui/presence/PresenceFragment.kt b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/ui/presence/PresenceFragment.kt new file mode 100644 index 0000000..6dcdd0c --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/ui/presence/PresenceFragment.kt @@ -0,0 +1,146 @@ +package it.danieleverducci.openweddingapp.ui.presence + +import android.animation.LayoutTransition +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import android.widget.Toast +import androidx.fragment.app.Fragment +import com.android.volley.VolleyError +import it.danieleverducci.openweddingapp.DeRApplication +import it.danieleverducci.openweddingapp.R +import it.danieleverducci.openweddingapp.databinding.FragmentPresenceBinding +import it.danieleverducci.openweddingapp.entities.Like +import it.danieleverducci.openweddingapp.entities.Presence +import it.danieleverducci.openweddingapp.networking.AuthenticatedItemNet +import it.danieleverducci.openweddingapp.networking.LikeNet +import it.danieleverducci.openweddingapp.networking.PresenceNet +import it.danieleverducci.openweddingapp.ui.gallery.GalleryFragment +import it.danieleverducci.openweddingapp.utils.SharedPreferencesManager + +class PresenceFragment : Fragment() { + companion object { + private val TAG = "PresenceFragment" + } + private lateinit var binding: FragmentPresenceBinding + private lateinit var presenceNet: PresenceNet + private var presenceToggleStatus: Boolean? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + presenceNet = PresenceNet(requireContext(), (activity!!.application as DeRApplication).token!!.token) + binding = FragmentPresenceBinding.inflate(inflater, container, false) + + // Enable animations for buttons size change + binding.presenceButtonsContainer.getLayoutTransition() + .enableTransitionType(LayoutTransition.CHANGING); + + // Hide buttons if user responded + val savedPresence = SharedPreferencesManager.loadPresence(requireContext()) + showPresence(savedPresence) + + if (savedPresence == null) { + // Check also presence online in case it differs (same user with multiple devices) + binding.progress.visibility = View.VISIBLE + presenceNet.getList(object : AuthenticatedItemNet.OnItemListObtainedListener { + override fun OnItemListObtained( + items: ArrayList?, + page: Int, + more: Boolean + ) { + binding.progress.visibility = View.GONE + if (items != null && items.size > 0) { + val presenceFromNet = items.get(0) + SharedPreferencesManager.savePresence(requireContext(), presenceFromNet) + showPresence(presenceFromNet) + } else { + showPresence(null) + } + } + + override fun OnError(error: VolleyError) { + binding.progress.visibility = View.GONE + Log.e(TAG, "Unable to obtain user presence answer") + } + + }) + } + + binding.presenceSumbit.setOnClickListener { + val presence = Presence(this.presenceToggleStatus!!, binding.presenceNotes.text.toString()) + sendPresence(presence) + } + + binding.presenceYes.setOnClickListener { + onPresenceButtonClicked(true) + } + + binding.presenceNo.setOnClickListener { + onPresenceButtonClicked(false) + } + + return binding.getRoot() + } + + private fun onPresenceButtonClicked(present: Boolean) { + // Colors + binding.presenceYes.setBackgroundResource( + if(present) R.drawable.button_background else R.drawable.button_background_secondary + ) + binding.presenceNo.setBackgroundResource( + if(present) R.drawable.button_background_secondary else R.drawable.button_background + ) + // Dimension + binding.presenceYes.layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, if(present) 2.0f else 1.0f) + binding.presenceNo.layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, if(present) 1.0f else 2.0f) + // Notes text box + binding.presenceNotes.visibility = if(present) View.VISIBLE else View.GONE + // Value + this.presenceToggleStatus = present + binding.presenceAnswerAnswering.visibility = View.VISIBLE + } + + private fun sendPresence(p: Presence) { + binding.progress.visibility = View.VISIBLE + presenceNet.postItem( + object : AuthenticatedItemNet.OnItemPostedListener { + override fun OnItemPosted(rp: Presence) { + binding.progress.visibility = View.GONE + SharedPreferencesManager.savePresence(requireContext(), rp) + showPresence(rp) + } + + override fun OnError(error: VolleyError) { + binding.progress.visibility = View.GONE + Log.e(TAG, error.message ?: "") + Toast.makeText(context, R.string.presence_error, Toast.LENGTH_SHORT) + .show() + } + }, + p + ) + } + + /** + * Shows/hides presence buttons and text + */ + private fun showPresence(p: Presence?) { + if (p == null) { + binding.presenceAnswerUnanswered.visibility = View.VISIBLE + binding.presenceAnswerAnsweredYes.visibility = View.GONE + binding.presenceAnswerAnsweredNo.visibility = View.GONE + return + } + + binding.presenceAnswerUnanswered.visibility = View.GONE + binding.presenceAnswerAnsweredYes.visibility = if (p.willBePresent) View.VISIBLE else View.GONE + binding.presenceAnswerAnsweredNo.visibility = if (p.willBePresent) View.GONE else View.VISIBLE + binding.presenceNotes.visibility = View.GONE + } +} \ No newline at end of file diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/ui/table/TableFragment.kt b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/ui/table/TableFragment.kt new file mode 100644 index 0000000..78d0cab --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/ui/table/TableFragment.kt @@ -0,0 +1,62 @@ +package it.danieleverducci.openweddingapp.ui.table + +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import com.android.volley.VolleyError +import it.danieleverducci.openweddingapp.DeRApplication +import it.danieleverducci.openweddingapp.R +import it.danieleverducci.openweddingapp.databinding.FragmentTableBinding +import it.danieleverducci.openweddingapp.entities.Table +import it.danieleverducci.openweddingapp.networking.AuthenticatedItemNet +import it.danieleverducci.openweddingapp.networking.TableNet + +class TableFragment: Fragment() { + companion object { + val TAG = "TableFragment" + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val binding = FragmentTableBinding.inflate(inflater, container, false) + val user = (activity?.application as DeRApplication).token?.user + + // Check if user can see this page + if ((activity!!.application as DeRApplication).remoteSettings.showTableEnabled) { + + // User can see page + binding.tableTitle.text = + getString(R.string.table_title).format(user?.name, user?.surname) + binding.tableName.text = user?.table + + // Load data + val tn = TableNet(requireContext(), (activity!!.application as DeRApplication).token!!.token) + tn.getItem(object : AuthenticatedItemNet.OnItemObtainedListener
{ + override fun OnItemObtained(item: Table) { + // Re-set table just to be sure (may have changed after the user log in) + binding.tableName.text = item.table + binding.tablePeople.visibility = View.VISIBLE + binding.tablePeople.text = + "${getString(R.string.table_footer)} ${item.getPeoplePrintableList()}" + } + + override fun OnError(error: VolleyError) { + Log.e(TAG, error.toString()) + } + }) + } else { + // User can't see page + binding.tableTitle.text = getString(R.string.table_title_disabled) + binding.tableName.visibility = View.GONE + binding.tablePeople.visibility = View.GONE + } + return binding.root + } + +} \ No newline at end of file diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/ui/weddinggift/WeddingGiftFragment.kt b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/ui/weddinggift/WeddingGiftFragment.kt new file mode 100644 index 0000000..d1df2be --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/ui/weddinggift/WeddingGiftFragment.kt @@ -0,0 +1,21 @@ +package it.danieleverducci.openweddingapp.ui.weddinggift + +import it.danieleverducci.openweddingapp.Config +import it.danieleverducci.openweddingapp.R +import it.danieleverducci.openweddingapp.ui.GenericLocalizedContentFragment + +class WeddingGiftFragment : GenericLocalizedContentFragment() { + + override fun getName(): String { + return "WeddingGift" + } + + override fun getEndpoint(): String { + return Config.STATIC_ENDPOINT_WEDDING_GIFT + } + + override fun getDefaultRawRes(): Int { + return R.raw.default_json_wg + } + +} \ No newline at end of file diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/utils/FileUtils.kt b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/utils/FileUtils.kt new file mode 100644 index 0000000..c9b9398 --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/utils/FileUtils.kt @@ -0,0 +1,33 @@ +package it.danieleverducci.openweddingapp.utils + +import android.content.Context +import android.os.Environment +import it.danieleverducci.openweddingapp.Config +import java.io.File +import java.io.IOException +import java.text.SimpleDateFormat +import java.util.* + +class FileUtils { + companion object { + + private fun getLocalImagePath(context: Context): File { + return context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)!! + } + + @Throws(IOException::class) + fun createTempImageFile(context: Context): File { + return File.createTempFile(UUID.randomUUID().toString(), null, context.cacheDir) + } + + @Throws(IOException::class) + fun createImageFile(context: Context): File { + // Create an image file name + val timeStamp: String = SimpleDateFormat("yyyyMMdd_HHmmss").format(Date()) + val storageDir: File = getLocalImagePath(context) + return File.createTempFile( + "${Config.SAVED_FILES_BASE_NAME}_${timeStamp}_",".jpg", storageDir + ) + } + } +} \ No newline at end of file diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/utils/ImageUtils.kt b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/utils/ImageUtils.kt new file mode 100644 index 0000000..a0194ba --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/utils/ImageUtils.kt @@ -0,0 +1,156 @@ +package it.danieleverducci.openweddingapp.utils + +import android.R.attr.bitmap +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ResolveInfo +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Matrix +import android.graphics.drawable.Drawable +import android.media.ExifInterface +import android.net.Uri +import android.util.Log +import androidx.core.content.FileProvider +import com.squareup.picasso.Picasso +import com.squareup.picasso.RequestCreator +import com.squareup.picasso.Target +import it.danieleverducci.openweddingapp.Config +import it.danieleverducci.openweddingapp.R +import java.io.BufferedOutputStream +import java.io.File +import java.io.FileOutputStream +import java.io.OutputStream + + +class ImageUtils { + companion object { + private const val MAX_SIZE = 2048 + private const val TAG = "ImageUtils" + private const val CACHE_DIRECTORY = "${Config.SAVED_FILES_BASE_NAME}_cache/" + const val SHARE_FILE_NAME = "share.jpg" + private var shareImgTarget: Target? = null + + fun resizeImage(file: File) { + Log.d("FILESIZE", "Old: ${file.length()}") + + val bfOpts = BitmapFactory.Options() + bfOpts.inJustDecodeBounds = true + BitmapFactory.decodeFile(file.path, bfOpts) + var scale = 1 + while (bfOpts.outWidth / scale / 2 > MAX_SIZE && bfOpts.outHeight / scale / 2 > MAX_SIZE) { + scale *= 2 + } + val outOptions = BitmapFactory.Options() + outOptions.inSampleSize = scale + val resizedBmp = BitmapFactory.decodeFile(file.path, outOptions) + val os = FileOutputStream(file.path); + resizedBmp.compress(Bitmap.CompressFormat.JPEG, 80, os) + os.flush() + os.close() + + val f2 = File(file.path) + Log.d("FILESIZE", "New: ${f2.length()}") + } + + fun shareImage(context: Context, urlOrPath: String, onImageSharedListener: OnImageSharedListener?) { + shareImgTarget = object: Target { + override fun onBitmapLoaded(bitmap: Bitmap?, from: Picasso.LoadedFrom?) { + if (bitmap != null) { + // Bitmap loaded: share! + shareBitmap(context, bitmap) + onImageSharedListener?.onImageShared() + } else { + Log.e(TAG, "Error loading bitmap for sharing image: bitmap is null!") + onImageSharedListener?.onImageShareFailed() + } + } + override fun onBitmapFailed(e: Exception?, errorDrawable: Drawable?) { + Log.e(TAG, "Unable to load bitmap for sharing image!") + onImageSharedListener?.onImageShareFailed() + } + override fun onPrepareLoad(placeHolderDrawable: Drawable?) { + Log.d(TAG, "onPrepareLoad") + } + } + + val picasso: RequestCreator + picasso = if (urlOrPath.startsWith("http")) + Picasso.get().load(urlOrPath) + else + Picasso.get().load(File(urlOrPath)) + + picasso.into(shareImgTarget!!) + } + + interface OnImageSharedListener { + fun onImageShared() + fun onImageShareFailed() + } + + private fun shareBitmap(context: Context, bmp: Bitmap) { + + val cachePath = File(context.externalCacheDir, CACHE_DIRECTORY) + cachePath.mkdirs() + + val sharedFile = File(cachePath, SHARE_FILE_NAME).also { file -> + FileOutputStream(file).use { fileOutputStream -> bmp.compress(Bitmap.CompressFormat.JPEG, 80, fileOutputStream) } + }.apply { + deleteOnExit() + } + + val shareImageFileUri: Uri = FileProvider.getUriForFile(context, context.applicationContext.packageName + ".fileprovider", sharedFile) + val shareMessage = context.getString(R.string.share_text) + + // Create the intent + val intent = Intent(Intent.ACTION_SEND).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + putExtra(Intent.EXTRA_STREAM, shareImageFileUri) + putExtra(Intent.EXTRA_TEXT, shareMessage) + type = "image/jpeg" + } + + // Initialize the share chooser + val chooserTitle = context.getString(R.string.share_chooser_text) + val chooser = Intent.createChooser(intent, chooserTitle) + val resInfoList: List = context.packageManager.queryIntentActivities(chooser, PackageManager.MATCH_DEFAULT_ONLY) + for (resolveInfo in resInfoList) { + val packageName: String = resolveInfo.activityInfo.packageName + context.grantUriPermission(packageName, shareImageFileUri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + + context.startActivity(chooser) + } + + /** + * Rotate an image if required. + * + * @param imgPath Image file path + * @return the rotated img path, or the original one if didn't need rotation + */ + fun rotateImageIfRequired(imgPath: File) { + val ei = ExifInterface(imgPath.path) + val orientation: Int = + ei.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL) + when (orientation) { + ExifInterface.ORIENTATION_ROTATE_90 -> rotateImage(imgPath, 90) + ExifInterface.ORIENTATION_ROTATE_180 -> rotateImage(imgPath, 180) + ExifInterface.ORIENTATION_ROTATE_270 -> rotateImage(imgPath, 270) + } + } + + private fun rotateImage(imgPath: File, degree: Int) { + val img = BitmapFactory.decodeFile(imgPath.path) + val matrix = Matrix() + matrix.postRotate(degree.toFloat()) + val rotatedImg = Bitmap.createBitmap(img, 0, 0, img.width, img.height, matrix, true) + img.recycle() + val os: OutputStream = BufferedOutputStream(FileOutputStream(imgPath)) + rotatedImg.compress(Bitmap.CompressFormat.JPEG, 80, os) + os.close() + rotatedImg.recycle() + } + } +} \ No newline at end of file diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/utils/NotificationPlanner.kt b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/utils/NotificationPlanner.kt new file mode 100644 index 0000000..44cdfa4 --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/utils/NotificationPlanner.kt @@ -0,0 +1,9 @@ +package it.danieleverducci.openweddingapp.utils + +class NotificationPlanner { + companion object { + fun plan () { + + } + } +} \ No newline at end of file diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/utils/NotificationUtils.kt b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/utils/NotificationUtils.kt new file mode 100644 index 0000000..01408cf --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/utils/NotificationUtils.kt @@ -0,0 +1,111 @@ +package it.danieleverducci.openweddingapp.utils + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import it.danieleverducci.openweddingapp.Config +import it.danieleverducci.openweddingapp.MainActivity +import it.danieleverducci.openweddingapp.R +import org.json.JSONException +import org.json.JSONObject +import java.lang.RuntimeException +import java.util.* +import java.util.regex.Pattern + +class NotificationUtils (val ctx: Context) { + companion object { + private val TAG = "NotificationUtils" + } + private val CHANNEL_ID = "123" + private var notificationId = 0 + + init { + // Create the NotificationChannel, but only on API 26+ because + // the NotificationChannel class is new and not in the support library + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val name = ctx.getString(R.string.channel_name) + val descriptionText = ctx.getString(R.string.channel_description) + val importance = NotificationManager.IMPORTANCE_DEFAULT + val channel = NotificationChannel(CHANNEL_ID, name, importance).apply { + description = descriptionText + } + // Register the channel with the system + val notificationManager: NotificationManager = + ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) + } + } + + fun createNotification(content: String) { + val intent = Intent(ctx, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + val pendingIntent: PendingIntent = PendingIntent.getActivity(ctx, 0, intent, PendingIntent.FLAG_IMMUTABLE) + + /* + Content is a JSON containing localization: + { + "it": { + "title": "Lorem ipsum", + "content": "Content content" + }, + "es": { + "title": "Lorem ipsum es", + "content": "Content content es" + } + } + */ + var contentJo: JSONObject? = null + try { + contentJo = JSONObject(content) + } catch (e: JSONException) { + Log.e(TAG, "Notification content is not a json!") + return + } + + val localized: JSONObject? = + if (contentJo.has(Locale.getDefault().language)) contentJo.getJSONObject(Locale.getDefault().language) + else contentJo.optJSONObject(Config.STATIC_CONTENT_DEFAULT_LOCALE) + + // Check language was found + if (localized == null) { + Log.e(TAG, "No supported language in received notification!") + return + } + + // Check required fields are present + val title = localized.optString("title") + val text = localized.optString("content") + if (title.isEmpty()) { + Log.e(TAG, "Missing title field in received notification!") + return + } + if (text.isEmpty()) { + Log.e(TAG, "Missing content field in received notification!") + return + } + + var builder = NotificationCompat.Builder(ctx, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_heart) + .setContentTitle(title) + .setContentText(text) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + + with(NotificationManagerCompat.from(ctx)) { + // notificationId is a unique int for each notification that you must define + notify(notificationId, builder.build()) + } + notificationId++ + + } + + +} \ No newline at end of file diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/utils/NotificationWorker.kt b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/utils/NotificationWorker.kt new file mode 100644 index 0000000..6c5a92e --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/utils/NotificationWorker.kt @@ -0,0 +1,66 @@ +package it.danieleverducci.openweddingapp.utils + +import android.content.Context +import android.util.Log +import androidx.work.Worker +import androidx.work.WorkerParameters +import com.android.volley.VolleyError +import it.danieleverducci.openweddingapp.Config +import it.danieleverducci.openweddingapp.entities.NtfyMessage +import it.danieleverducci.openweddingapp.networking.NtfySubscriptionNet + +class NotificationWorker(appContext: Context, workerParams: WorkerParameters) + : Worker(appContext, workerParams) { + + companion object { + val TAG = "NotificationWorker" + } + + val notificationUtils: NotificationUtils + val ntfySubscriptionNet: NtfySubscriptionNet + + init { + notificationUtils = NotificationUtils(applicationContext) + ntfySubscriptionNet = NtfySubscriptionNet(applicationContext) + } + + override fun doWork(): Result { + Log.d(TAG, "Checking for notifications") + + // Check for notifications + checkNtfyTopic(Config.NTFY_GENERAL_TOPIC) + val presence = SharedPreferencesManager.loadPresence(applicationContext) + if (presence == null) { + Log.d(TAG, "Checking notif for DNR") + checkNtfyTopic(Config.NTFY_DNR_TOPIC) + } else if (presence.willBePresent) { + Log.d(TAG, "Checking notif for YES") + checkNtfyTopic(Config.NTFY_WILLBETHERE_TOPIC) + } else { + Log.d(TAG, "Checking notif for NO") + checkNtfyTopic(Config.NTFY_WILLNOTBETHERE_TOPIC) + } + + SharedPreferencesManager.saveLocalSettingsLastNotificationCheck(applicationContext, System.currentTimeMillis()) + return Result.success() + } + + private fun checkNtfyTopic(topic: String) { + val ntfySubscriptionNet = NtfySubscriptionNet(applicationContext) + val lastCheckInSeconds = SharedPreferencesManager.loadLocalSettingsLastNotificationCheck(applicationContext) / 1000 + ntfySubscriptionNet.getItems("${Config.NTFY_BASE_URL}${topic}/json?poll=1", object: NtfySubscriptionNet.OnItemsObtainedListener { + + override fun OnItemsObtained(items: List) { + val nu = NotificationUtils(applicationContext) + for (ntfyItem in items) { + if (ntfyItem.time > lastCheckInSeconds) + nu.createNotification(ntfyItem.message) + } + } + + override fun OnError(error: VolleyError) { + Log.e(TAG, "Unable to obtain notifications for topic ${topic}: ${error}") + } + }) + } +} diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/utils/PackageUtils.kt b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/utils/PackageUtils.kt new file mode 100644 index 0000000..c23d4cf --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/utils/PackageUtils.kt @@ -0,0 +1,16 @@ +package it.danieleverducci.openweddingapp.utils + +import android.content.Context +import android.content.pm.PackageManager + + +object PackageUtils { + public fun isGoogleMapsInstalled(context: Context): Boolean { + return try { + context.getPackageManager().getApplicationInfo("com.google.android.apps.maps", 0) + true + } catch (e: PackageManager.NameNotFoundException) { + false + } + } +} \ No newline at end of file diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/utils/SharedPreferencesManager.kt b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/utils/SharedPreferencesManager.kt new file mode 100644 index 0000000..f4d8db7 --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/utils/SharedPreferencesManager.kt @@ -0,0 +1,255 @@ +package it.danieleverducci.openweddingapp.utils + +import android.content.Context +import it.danieleverducci.openweddingapp.R +import it.danieleverducci.openweddingapp.entities.* +import org.json.JSONArray +import org.json.JSONObject + +class SharedPreferencesManager { + companion object { + + // Other keys + private const val SP_NAME = "der_sp" + private const val TOKEN_SP_KEY = "token" + private const val REMOTE_SETTINGS_SP_KEY = "remote_settings" + private const val LOCATION_SP_KEY = "location" + private const val PRESENCE_SP_KEY = "presence" + private const val PLACES_SP_KEY = "places" + private const val GALLERY_CACHE_SP_KEY = "gallery_cache" + private const val LOCAL_SETTINGS_LASTNOTCHECK_SP_KEY = "ls_last_notifications_check" + private const val LOCAL_SETTINGS_NOTIFENABLED_SP_KEY = "ls_notifications_enabled" + private const val LOCAL_SETTINGS_FIRSTRUN_SP_KEY = "ls_first_run" + private const val LOCAL_SETTINGS_PLACESHINTDISPLAYED_SP_KEY = "places_hint_displayed" + + fun clear(ctx: Context) { + val sharedPref = ctx.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE) + sharedPref.edit().clear().commit() + } + + fun saveToken(ctx: Context, usr: Token) { + val sharedPref = ctx.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE) + with (sharedPref.edit()) { + putString(TOKEN_SP_KEY, usr.toJson().toString()) + commit() + } + } + + fun loadToken(ctx: Context): Token? { + val sharedPref = ctx.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE) + val usrString = sharedPref.getString(TOKEN_SP_KEY, null) + if (usrString == null) + return null + else + return Token(JSONObject(usrString)) + } + + fun deleteToken(ctx: Context) { + val sharedPref = ctx.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE) + with (sharedPref.edit()) { + remove(TOKEN_SP_KEY) + commit() + } + } + + fun saveLocation(ctx: Context, l: Location) { + val sharedPref = ctx.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE) + with (sharedPref.edit()) { + putString(LOCATION_SP_KEY, l.toJson().toString()) + commit() + } + } + + /** + * Load cached location from shared preferences. + * If no cached location is found, returns default location defined in default_json_location resource. + */ + fun loadLocation(ctx: Context): Location { + val sharedPref = ctx.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE) + var locString = sharedPref.getString(LOCATION_SP_KEY, null) + if (locString == null) { + // Load default location from resources + locString = ctx.resources.openRawResource(R.raw.default_json_location) + .bufferedReader().use { it.readText() } + } + return Location(JSONObject(locString)) + } + + /** + * Saves generic content + */ + fun saveGenericDynamicContent(ctx: Context, gdc: GenericDynamicContent, name: String) { + val sharedPref = ctx.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE) + with (sharedPref.edit()) { + putString(name, gdc.toJson().toString()) + commit() + } + } + + /** + * Load cached wg from shared preferences. + * If no cached wg is found, returns default one defined in provided raw resource. + * @param ctx Context + * @param name Shared preference key to load the content + * @param default Raw resource to load default content + */ + fun loadGenericDynamicContent(ctx: Context, name: String, default: Int): GenericDynamicContent { + val sharedPref = ctx.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE) + var gdcString = sharedPref.getString(name, null) + if (gdcString == null) { + // Load default content from resources + gdcString = ctx.resources.openRawResource(default) + .bufferedReader().use { it.readText() } + } + return GenericDynamicContent(JSONObject(gdcString)) + } + + fun savePresence(ctx: Context, p: Presence) { + val sharedPref = ctx.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE) + with (sharedPref.edit()) { + putString(PRESENCE_SP_KEY, p.toJson().toString()) + commit() + } + } + + /** + * Load presence from shared preferences. + * If the user didn't aswer yet, returns null. + */ + fun loadPresence(ctx: Context): Presence? { + val sharedPref = ctx.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE) + val pString = sharedPref.getString(PRESENCE_SP_KEY, null) + if (pString == null) + return null + return Presence(JSONObject(pString)) + } + + + /** + * Load cached settings from shared preferences. + * If no cached settings is found, returns default settings defined in default_json_remote_settings resource. + */ + fun loadRemoteSettings(ctx: Context): RemoteSettings { + val sharedPref = ctx.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE) + var sString = sharedPref.getString(REMOTE_SETTINGS_SP_KEY, null) + if (sString == null) { + // Load default settings from resources + sString = ctx.resources.openRawResource(R.raw.default_json_remote_settings) + .bufferedReader().use { it.readText() } + } + return RemoteSettings(JSONObject(sString)) + } + + fun saveRemoteSettings(ctx: Context, rs: RemoteSettings) { + val sharedPref = ctx.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE) + with (sharedPref.edit()) { + putString(REMOTE_SETTINGS_SP_KEY, rs.toJson().toString()) + commit() + } + } + + /** + * Last notification check timestamp. + */ + fun loadLocalSettingsLastNotificationCheck(ctx: Context): Long { + val sharedPref = ctx.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE) + return sharedPref.getLong(LOCAL_SETTINGS_LASTNOTCHECK_SP_KEY, 0) + } + + fun saveLocalSettingsLastNotificationCheck(ctx: Context, lastNotificationCheck: Long) { + val sharedPref = ctx.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE) + with (sharedPref.edit()) { + putLong(LOCAL_SETTINGS_LASTNOTCHECK_SP_KEY, lastNotificationCheck) + commit() + } + } + + /** + * Determine if the places fragment hint has already been displayed + */ + fun hasPlacesHintBeenDisplayed(ctx: Context): Boolean { + val sharedPref = ctx.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE) + val displayed = sharedPref.getBoolean(LOCAL_SETTINGS_PLACESHINTDISPLAYED_SP_KEY, false) + if (!displayed) { + with (sharedPref.edit()) { + putBoolean(LOCAL_SETTINGS_PLACESHINTDISPLAYED_SP_KEY, true) + commit() + } + } + return displayed + } + + /** + * Determines if it is first app run + */ + fun isFirstAppRun(ctx: Context): Boolean { + val sharedPref = ctx.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE) + val firstRun = sharedPref.getBoolean(LOCAL_SETTINGS_FIRSTRUN_SP_KEY, true) + if (firstRun) { + // Update value + with(sharedPref.edit()) { + putBoolean(LOCAL_SETTINGS_FIRSTRUN_SP_KEY, false) + commit() + } + } + return firstRun + } + + /** + * Caches gallery first page request, in case of no connection. + */ + fun saveGalleryCache(ctx: Context, gi: ArrayList) { + val sharedPref = ctx.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE) + with (sharedPref.edit()) { + val jGiArr = JSONArray() + for (i in 0 until gi.size) { + jGiArr.put(gi.get(i).toJson()) + } + putString(GALLERY_CACHE_SP_KEY, jGiArr.toString()) + commit() + } + } + + /** + * Load cached gallery first page. + * If no cached gallery first page is found, returns NULL. + */ + fun loadGalleryCache(ctx: Context): ArrayList? { + val sharedPref = ctx.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE) + var giString = sharedPref.getString(GALLERY_CACHE_SP_KEY, null) + if (giString == null) + return null + val jGiArr = JSONArray(giString) + val gi = ArrayList(jGiArr.length()) + for (i in 0 until jGiArr.length()) { + gi.add(GalleryItem(jGiArr.getJSONObject(i))) + } + return gi + } + + /* Save places info */ + fun savePlaces(ctx: Context, p: JSONObject) { + val sharedPref = ctx.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE) + with (sharedPref.edit()) { + putString(PLACES_SP_KEY, p.toString()) + commit() + } + } + + /** + * Load cached location from shared preferences. + * If no cached location is found, returns default location defined in default_json_location resource. + */ + fun loadPlaces(ctx: Context): JSONObject { + val sharedPref = ctx.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE) + var placesString = sharedPref.getString(PLACES_SP_KEY, null) + if (placesString == null) { + // Load default location from resources + placesString = ctx.resources.openRawResource(R.raw.default_json_places) + .bufferedReader().use { it.readText() } + } + return JSONObject(placesString) + } + + } +} \ No newline at end of file diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/utils/ViewUtils.kt b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/utils/ViewUtils.kt new file mode 100644 index 0000000..930723c --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/utils/ViewUtils.kt @@ -0,0 +1,43 @@ +package it.danieleverducci.openweddingapp.utils + +import android.os.Build +import android.widget.TextView +import androidx.core.graphics.drawable.DrawableCompat +import it.danieleverducci.openweddingapp.entities.User +import java.text.Normalizer +import java.util.Locale +import java.util.Locale.getDefault + +object ViewUtils { + fun colorizeUserBadge(badge: TextView, user: User) { + val bgDrawable = DrawableCompat.wrap(badge.getBackground()) + if (Build.VERSION.SDK_INT >= 21) + bgDrawable.setTint(user.badgeColor) + } + + /** + * Returns a color component (0-255) from the provided string + */ + fun getUserBadgeColorComponentFromString(string: String, channel: Int): Int { + if (string.length - 1 < channel) + return 0 + + // Remove accents and strange symbols and toUppercase + val normStr = Normalizer.normalize(string.uppercase(getDefault()), Normalizer.Form.NFD) + .replace("\\p{Mn}+".toRegex(), "") + + // Get desired channel int from string + var channel = normStr.get(channel).toInt() + + // Remap ascii values A-Z (65-90) to 0-255 + if (channel < 65) + channel = 0 + else if (channel > 90) + channel = 255 + else + channel = ((channel - 65).toFloat() / 25 * 255).toInt() + + // Be sure the value is between 0 and 255 + return Math.max(Math.min(channel, 255), 0) + } +} \ No newline at end of file diff --git a/android-app/app/src/main/java/it/danieleverducci/danieleeromina/utils/WifiUtils.kt b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/utils/WifiUtils.kt new file mode 100644 index 0000000..4b3cbcc --- /dev/null +++ b/android-app/app/src/main/java/it/danieleverducci/danieleeromina/utils/WifiUtils.kt @@ -0,0 +1,92 @@ +package it.danieleverducci.openweddingapp.utils + +import android.app.AlertDialog +import android.content.Context +import android.content.Context.WIFI_SERVICE +import android.content.DialogInterface +import android.net.wifi.WifiConfiguration +import android.net.wifi.WifiManager +import android.net.wifi.WifiNetworkSuggestion +import android.os.Build +import android.util.Log +import android.widget.Toast +import it.danieleverducci.openweddingapp.R +import it.danieleverducci.openweddingapp.entities.WifiNetwork + + +object WifiUtils { + const val TAG = "WifiUtils" + + /** + * Shows a dialog asking the user if he want to connect to the wedding's wifi network + * If the user answers yes, adds the wedding wifi networks to system keychain + */ + fun askToAddWeddingNetworks(context: Context, networks: List) { + if (networks.isEmpty()) { + Log.e(TAG, "Network list is empty: ignoring connection to wifi dialog") + return + } + + val builder: AlertDialog.Builder = context.let { + AlertDialog.Builder(it, R.style.AlertDialogCustom) + } + builder.apply { + setMessage(R.string.dialog_wifinetwork_message) + setTitle(R.string.dialog_wifinetwork_title) + setPositiveButton(android.R.string.ok) { _, _ -> + addWeddingNetworks(context, networks) + Toast.makeText(context, R.string.dialog_wifinetwork_waitplease, Toast.LENGTH_LONG).show() + } + setNegativeButton(android.R.string.cancel) { dialog, _ -> + dialog.dismiss() + } + } + builder.create().show() + } + + /** + * Adds wedding wifi networks to system keychain + */ + fun addWeddingNetworks(context: Context, networks: List) { + if (networks.isEmpty()) { + Log.e(TAG, "Network list is empty: ignoring adding wifi connection") + return + } + if (networks.isEmpty()) + return + + val wifiManager = context.getApplicationContext() + .getSystemService(WIFI_SERVICE) as WifiManager + val list: MutableList = ArrayList() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + for (network in networks) { + val wifiNetworkSuggestionBuilder = WifiNetworkSuggestion.Builder() + wifiNetworkSuggestionBuilder.setSsid(network.ssid) + wifiNetworkSuggestionBuilder.setWpa2Passphrase(network.password) + val wifiNetworkSuggestion = wifiNetworkSuggestionBuilder.build() + list.add(wifiNetworkSuggestion) + } + + wifiManager.removeNetworkSuggestions(ArrayList()) + wifiManager.addNetworkSuggestions(list) + } else { + var first = true + for (network in networks) { + val wifiConfig = WifiConfiguration() + + wifiConfig.SSID = String.format("\"%s\"", network.ssid) + wifiConfig.preSharedKey = String.format("\"%s\"", network.password) + + val netId = wifiManager.addNetwork(wifiConfig) + + if (first) { + // First connection is the more important one. Try to connect. + wifiManager.disconnect() + wifiManager.enableNetwork(netId, true) + wifiManager.reconnect() + first = false + } + } + } + } +} \ No newline at end of file diff --git a/android-app/app/src/main/res/anim/like_button.xml b/android-app/app/src/main/res/anim/like_button.xml new file mode 100644 index 0000000..73ed31f --- /dev/null +++ b/android-app/app/src/main/res/anim/like_button.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/android-app/app/src/main/res/anim/slide_in_right.xml b/android-app/app/src/main/res/anim/slide_in_right.xml new file mode 100644 index 0000000..a2f89be --- /dev/null +++ b/android-app/app/src/main/res/anim/slide_in_right.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/android-app/app/src/main/res/anim/slide_out_right.xml b/android-app/app/src/main/res/anim/slide_out_right.xml new file mode 100644 index 0000000..30ca837 --- /dev/null +++ b/android-app/app/src/main/res/anim/slide_out_right.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/android-app/app/src/main/res/drawable-hdpi/ic_map_ceremony.png b/android-app/app/src/main/res/drawable-hdpi/ic_map_ceremony.png new file mode 100644 index 0000000..44adb00 Binary files /dev/null and b/android-app/app/src/main/res/drawable-hdpi/ic_map_ceremony.png differ diff --git a/android-app/app/src/main/res/drawable-mdpi/ic_map_ceremony.png b/android-app/app/src/main/res/drawable-mdpi/ic_map_ceremony.png new file mode 100644 index 0000000..fc8b63a Binary files /dev/null and b/android-app/app/src/main/res/drawable-mdpi/ic_map_ceremony.png differ diff --git a/android-app/app/src/main/res/drawable-v21/ic_menu_gallery.xml b/android-app/app/src/main/res/drawable-v21/ic_menu_gallery.xml new file mode 100644 index 0000000..03c7709 --- /dev/null +++ b/android-app/app/src/main/res/drawable-v21/ic_menu_gallery.xml @@ -0,0 +1,9 @@ + + + diff --git a/android-app/app/src/main/res/drawable-v21/ic_menu_manage.xml b/android-app/app/src/main/res/drawable-v21/ic_menu_manage.xml new file mode 100644 index 0000000..aeb047d --- /dev/null +++ b/android-app/app/src/main/res/drawable-v21/ic_menu_manage.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/android-app/app/src/main/res/drawable-v21/ic_menu_send.xml b/android-app/app/src/main/res/drawable-v21/ic_menu_send.xml new file mode 100644 index 0000000..fdf1c90 --- /dev/null +++ b/android-app/app/src/main/res/drawable-v21/ic_menu_send.xml @@ -0,0 +1,9 @@ + + + diff --git a/android-app/app/src/main/res/drawable-v21/ic_menu_share.xml b/android-app/app/src/main/res/drawable-v21/ic_menu_share.xml new file mode 100644 index 0000000..338d95a --- /dev/null +++ b/android-app/app/src/main/res/drawable-v21/ic_menu_share.xml @@ -0,0 +1,9 @@ + + + diff --git a/android-app/app/src/main/res/drawable-v21/ic_menu_slideshow.xml b/android-app/app/src/main/res/drawable-v21/ic_menu_slideshow.xml new file mode 100644 index 0000000..5e9e163 --- /dev/null +++ b/android-app/app/src/main/res/drawable-v21/ic_menu_slideshow.xml @@ -0,0 +1,9 @@ + + + diff --git a/android-app/app/src/main/res/drawable-v24/ic_map_pin.xml b/android-app/app/src/main/res/drawable-v24/ic_map_pin.xml new file mode 100644 index 0000000..bc77910 --- /dev/null +++ b/android-app/app/src/main/res/drawable-v24/ic_map_pin.xml @@ -0,0 +1,5 @@ + + + diff --git a/android-app/app/src/main/res/drawable-xhdpi/ic_map_ceremony.png b/android-app/app/src/main/res/drawable-xhdpi/ic_map_ceremony.png new file mode 100644 index 0000000..4d800e4 Binary files /dev/null and b/android-app/app/src/main/res/drawable-xhdpi/ic_map_ceremony.png differ diff --git a/android-app/app/src/main/res/drawable-xxhdpi/ic_map_ceremony.png b/android-app/app/src/main/res/drawable-xxhdpi/ic_map_ceremony.png new file mode 100644 index 0000000..c1666cb Binary files /dev/null and b/android-app/app/src/main/res/drawable-xxhdpi/ic_map_ceremony.png differ diff --git a/android-app/app/src/main/res/drawable/background_gallery.xml b/android-app/app/src/main/res/drawable/background_gallery.xml new file mode 100644 index 0000000..ff1dee7 --- /dev/null +++ b/android-app/app/src/main/res/drawable/background_gallery.xml @@ -0,0 +1,13 @@ + + + + + + + + + + \ No newline at end of file diff --git a/android-app/app/src/main/res/drawable/background_loading_screen.xml b/android-app/app/src/main/res/drawable/background_loading_screen.xml new file mode 100644 index 0000000..b1528d6 --- /dev/null +++ b/android-app/app/src/main/res/drawable/background_loading_screen.xml @@ -0,0 +1,13 @@ + + + + + + + + + + \ No newline at end of file diff --git a/android-app/app/src/main/res/drawable/button_background.xml b/android-app/app/src/main/res/drawable/button_background.xml new file mode 100644 index 0000000..9d0dc3f --- /dev/null +++ b/android-app/app/src/main/res/drawable/button_background.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/android-app/app/src/main/res/drawable/button_background_pressed.xml b/android-app/app/src/main/res/drawable/button_background_pressed.xml new file mode 100644 index 0000000..d0c644f --- /dev/null +++ b/android-app/app/src/main/res/drawable/button_background_pressed.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/android-app/app/src/main/res/drawable/button_background_round.xml b/android-app/app/src/main/res/drawable/button_background_round.xml new file mode 100644 index 0000000..93667ee --- /dev/null +++ b/android-app/app/src/main/res/drawable/button_background_round.xml @@ -0,0 +1,5 @@ + + + + diff --git a/android-app/app/src/main/res/drawable/button_background_round_pressed.xml b/android-app/app/src/main/res/drawable/button_background_round_pressed.xml new file mode 100644 index 0000000..8a92982 --- /dev/null +++ b/android-app/app/src/main/res/drawable/button_background_round_pressed.xml @@ -0,0 +1,5 @@ + + + + diff --git a/android-app/app/src/main/res/drawable/button_background_secondary.xml b/android-app/app/src/main/res/drawable/button_background_secondary.xml new file mode 100644 index 0000000..01de0af --- /dev/null +++ b/android-app/app/src/main/res/drawable/button_background_secondary.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/android-app/app/src/main/res/drawable/button_background_secondary_pressed.xml b/android-app/app/src/main/res/drawable/button_background_secondary_pressed.xml new file mode 100644 index 0000000..0cf727c --- /dev/null +++ b/android-app/app/src/main/res/drawable/button_background_secondary_pressed.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/android-app/app/src/main/res/drawable/button_background_statedrawable.xml b/android-app/app/src/main/res/drawable/button_background_statedrawable.xml new file mode 100644 index 0000000..90b3dd6 --- /dev/null +++ b/android-app/app/src/main/res/drawable/button_background_statedrawable.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android-app/app/src/main/res/drawable/button_background_statedrawable_round.xml b/android-app/app/src/main/res/drawable/button_background_statedrawable_round.xml new file mode 100644 index 0000000..2a5aaad --- /dev/null +++ b/android-app/app/src/main/res/drawable/button_background_statedrawable_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android-app/app/src/main/res/drawable/button_background_statedrawable_secondary.xml b/android-app/app/src/main/res/drawable/button_background_statedrawable_secondary.xml new file mode 100644 index 0000000..68e8276 --- /dev/null +++ b/android-app/app/src/main/res/drawable/button_background_statedrawable_secondary.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android-app/app/src/main/res/drawable/edittext_background.xml b/android-app/app/src/main/res/drawable/edittext_background.xml new file mode 100644 index 0000000..a163c42 --- /dev/null +++ b/android-app/app/src/main/res/drawable/edittext_background.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/android-app/app/src/main/res/drawable/gallery_like_button_background_active.xml b/android-app/app/src/main/res/drawable/gallery_like_button_background_active.xml new file mode 100644 index 0000000..1e7b2a0 --- /dev/null +++ b/android-app/app/src/main/res/drawable/gallery_like_button_background_active.xml @@ -0,0 +1,5 @@ + + + + diff --git a/android-app/app/src/main/res/drawable/gallery_like_button_background_inactive.xml b/android-app/app/src/main/res/drawable/gallery_like_button_background_inactive.xml new file mode 100644 index 0000000..027eb53 --- /dev/null +++ b/android-app/app/src/main/res/drawable/gallery_like_button_background_inactive.xml @@ -0,0 +1,5 @@ + + + + diff --git a/android-app/app/src/main/res/drawable/ic_add_a_photo.xml b/android-app/app/src/main/res/drawable/ic_add_a_photo.xml new file mode 100644 index 0000000..3d2ba42 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_add_a_photo.xml @@ -0,0 +1,9 @@ + + + diff --git a/android-app/app/src/main/res/drawable/ic_close.xml b/android-app/app/src/main/res/drawable/ic_close.xml new file mode 100644 index 0000000..ede4b71 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_close.xml @@ -0,0 +1,9 @@ + + + diff --git a/android-app/app/src/main/res/drawable/ic_copy.xml b/android-app/app/src/main/res/drawable/ic_copy.xml new file mode 100644 index 0000000..bac0f60 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_copy.xml @@ -0,0 +1,5 @@ + + + diff --git a/android-app/app/src/main/res/drawable/ic_decorations.xml b/android-app/app/src/main/res/drawable/ic_decorations.xml new file mode 100644 index 0000000..8233677 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_decorations.xml @@ -0,0 +1,10 @@ + + + diff --git a/android-app/app/src/main/res/drawable/ic_directions.xml b/android-app/app/src/main/res/drawable/ic_directions.xml new file mode 100644 index 0000000..f86a0fd --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_directions.xml @@ -0,0 +1,10 @@ + + + diff --git a/android-app/app/src/main/res/drawable/ic_done.xml b/android-app/app/src/main/res/drawable/ic_done.xml new file mode 100644 index 0000000..0f20b28 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_done.xml @@ -0,0 +1,10 @@ + + + diff --git a/android-app/app/src/main/res/drawable/ic_download.xml b/android-app/app/src/main/res/drawable/ic_download.xml new file mode 100644 index 0000000..987f215 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_download.xml @@ -0,0 +1,5 @@ + + + diff --git a/android-app/app/src/main/res/drawable/ic_heart.xml b/android-app/app/src/main/res/drawable/ic_heart.xml new file mode 100644 index 0000000..c346f56 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_heart.xml @@ -0,0 +1,10 @@ + + + diff --git a/android-app/app/src/main/res/drawable/ic_logout.xml b/android-app/app/src/main/res/drawable/ic_logout.xml new file mode 100644 index 0000000..2059dea --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_logout.xml @@ -0,0 +1,11 @@ + + + diff --git a/android-app/app/src/main/res/drawable/ic_map_appetizer.xml b/android-app/app/src/main/res/drawable/ic_map_appetizer.xml new file mode 100644 index 0000000..01a0cd9 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_map_appetizer.xml @@ -0,0 +1,11 @@ + + + diff --git a/android-app/app/src/main/res/drawable/ic_map_book.xml b/android-app/app/src/main/res/drawable/ic_map_book.xml new file mode 100644 index 0000000..83aa095 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_map_book.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/android-app/app/src/main/res/drawable/ic_map_cigars.xml b/android-app/app/src/main/res/drawable/ic_map_cigars.xml new file mode 100644 index 0000000..2b6964e --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_map_cigars.xml @@ -0,0 +1,10 @@ + + + diff --git a/android-app/app/src/main/res/drawable/ic_map_ecology.xml b/android-app/app/src/main/res/drawable/ic_map_ecology.xml new file mode 100644 index 0000000..460c432 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_map_ecology.xml @@ -0,0 +1,10 @@ + + + diff --git a/android-app/app/src/main/res/drawable/ic_map_etnic.xml b/android-app/app/src/main/res/drawable/ic_map_etnic.xml new file mode 100644 index 0000000..b2264a2 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_map_etnic.xml @@ -0,0 +1,5 @@ + + + diff --git a/android-app/app/src/main/res/drawable/ic_map_games.xml b/android-app/app/src/main/res/drawable/ic_map_games.xml new file mode 100644 index 0000000..cbe7e27 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_map_games.xml @@ -0,0 +1,5 @@ + + + diff --git a/android-app/app/src/main/res/drawable/ic_map_liquor.xml b/android-app/app/src/main/res/drawable/ic_map_liquor.xml new file mode 100644 index 0000000..b952be6 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_map_liquor.xml @@ -0,0 +1,13 @@ + + + + diff --git a/android-app/app/src/main/res/drawable/ic_map_lunch.xml b/android-app/app/src/main/res/drawable/ic_map_lunch.xml new file mode 100644 index 0000000..5a7beac --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_map_lunch.xml @@ -0,0 +1,10 @@ + + + diff --git a/android-app/app/src/main/res/drawable/ic_map_mate.xml b/android-app/app/src/main/res/drawable/ic_map_mate.xml new file mode 100644 index 0000000..ea2d8ab --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_map_mate.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/android-app/app/src/main/res/drawable/ic_map_parking.xml b/android-app/app/src/main/res/drawable/ic_map_parking.xml new file mode 100644 index 0000000..e706be3 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_map_parking.xml @@ -0,0 +1,10 @@ + + + diff --git a/android-app/app/src/main/res/drawable/ic_map_photo.xml b/android-app/app/src/main/res/drawable/ic_map_photo.xml new file mode 100644 index 0000000..7953046 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_map_photo.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/android-app/app/src/main/res/drawable/ic_menu_attendee_gift.xml b/android-app/app/src/main/res/drawable/ic_menu_attendee_gift.xml new file mode 100644 index 0000000..777eceb --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_menu_attendee_gift.xml @@ -0,0 +1,10 @@ + + + diff --git a/android-app/app/src/main/res/drawable/ic_menu_food_menu.xml b/android-app/app/src/main/res/drawable/ic_menu_food_menu.xml new file mode 100644 index 0000000..4797566 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_menu_food_menu.xml @@ -0,0 +1,10 @@ + + + diff --git a/android-app/app/src/main/res/drawable/ic_menu_info.xml b/android-app/app/src/main/res/drawable/ic_menu_info.xml new file mode 100644 index 0000000..17255b7 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_menu_info.xml @@ -0,0 +1,10 @@ + + + diff --git a/android-app/app/src/main/res/drawable/ic_menu_location.xml b/android-app/app/src/main/res/drawable/ic_menu_location.xml new file mode 100644 index 0000000..e6dfeb4 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_menu_location.xml @@ -0,0 +1,10 @@ + + + diff --git a/android-app/app/src/main/res/drawable/ic_menu_places.xml b/android-app/app/src/main/res/drawable/ic_menu_places.xml new file mode 100644 index 0000000..d1274d8 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_menu_places.xml @@ -0,0 +1,10 @@ + + + diff --git a/android-app/app/src/main/res/drawable/ic_menu_presence.xml b/android-app/app/src/main/res/drawable/ic_menu_presence.xml new file mode 100644 index 0000000..79ada3c --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_menu_presence.xml @@ -0,0 +1,10 @@ + + + diff --git a/android-app/app/src/main/res/drawable/ic_menu_table.xml b/android-app/app/src/main/res/drawable/ic_menu_table.xml new file mode 100644 index 0000000..db7cd04 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_menu_table.xml @@ -0,0 +1,10 @@ + + + diff --git a/android-app/app/src/main/res/drawable/ic_menu_wedding_gift.xml b/android-app/app/src/main/res/drawable/ic_menu_wedding_gift.xml new file mode 100644 index 0000000..6e8a152 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_menu_wedding_gift.xml @@ -0,0 +1,10 @@ + + + diff --git a/android-app/app/src/main/res/drawable/ic_places_hint.xml b/android-app/app/src/main/res/drawable/ic_places_hint.xml new file mode 100644 index 0000000..c898000 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_places_hint.xml @@ -0,0 +1,5 @@ + + + diff --git a/android-app/app/src/main/res/drawable/ic_presence.xml b/android-app/app/src/main/res/drawable/ic_presence.xml new file mode 100644 index 0000000..0250651 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_presence.xml @@ -0,0 +1,10 @@ + + + diff --git a/android-app/app/src/main/res/drawable/ic_presence_send.xml b/android-app/app/src/main/res/drawable/ic_presence_send.xml new file mode 100644 index 0000000..f0d63e1 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_presence_send.xml @@ -0,0 +1,11 @@ + + + diff --git a/android-app/app/src/main/res/drawable/ic_privacy.xml b/android-app/app/src/main/res/drawable/ic_privacy.xml new file mode 100644 index 0000000..99e31f2 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_privacy.xml @@ -0,0 +1,10 @@ + + + diff --git a/android-app/app/src/main/res/drawable/ic_qr_code.xml b/android-app/app/src/main/res/drawable/ic_qr_code.xml new file mode 100644 index 0000000..6918603 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_qr_code.xml @@ -0,0 +1,10 @@ + + + diff --git a/android-app/app/src/main/res/drawable/ic_select_a_photo.xml b/android-app/app/src/main/res/drawable/ic_select_a_photo.xml new file mode 100644 index 0000000..8232c4d --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_select_a_photo.xml @@ -0,0 +1,10 @@ + + + diff --git a/android-app/app/src/main/res/drawable/ic_time.xml b/android-app/app/src/main/res/drawable/ic_time.xml new file mode 100644 index 0000000..86533bf --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_time.xml @@ -0,0 +1,13 @@ + + + + diff --git a/android-app/app/src/main/res/drawable/ic_user.xml b/android-app/app/src/main/res/drawable/ic_user.xml new file mode 100644 index 0000000..6bdced2 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_user.xml @@ -0,0 +1,10 @@ + + + diff --git a/android-app/app/src/main/res/drawable/placeholder.xml b/android-app/app/src/main/res/drawable/placeholder.xml new file mode 100644 index 0000000..393b768 --- /dev/null +++ b/android-app/app/src/main/res/drawable/placeholder.xml @@ -0,0 +1,10 @@ + + + diff --git a/android-app/app/src/main/res/drawable/places_time_badge_background.xml b/android-app/app/src/main/res/drawable/places_time_badge_background.xml new file mode 100644 index 0000000..4a793af --- /dev/null +++ b/android-app/app/src/main/res/drawable/places_time_badge_background.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/android-app/app/src/main/res/drawable/side_nav_bar.jpg b/android-app/app/src/main/res/drawable/side_nav_bar.jpg new file mode 100644 index 0000000..a95ed03 Binary files /dev/null and b/android-app/app/src/main/res/drawable/side_nav_bar.jpg differ diff --git a/android-app/app/src/main/res/drawable/user_badge.xml b/android-app/app/src/main/res/drawable/user_badge.xml new file mode 100644 index 0000000..0334428 --- /dev/null +++ b/android-app/app/src/main/res/drawable/user_badge.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/android-app/app/src/main/res/font/ephesis.ttf b/android-app/app/src/main/res/font/ephesis.ttf new file mode 100644 index 0000000..ba1ab8f Binary files /dev/null and b/android-app/app/src/main/res/font/ephesis.ttf differ diff --git a/android-app/app/src/main/res/font/font.ttf b/android-app/app/src/main/res/font/font.ttf new file mode 100644 index 0000000..e020872 Binary files /dev/null and b/android-app/app/src/main/res/font/font.ttf differ diff --git a/android-app/app/src/main/res/font/monospace.ttf b/android-app/app/src/main/res/font/monospace.ttf new file mode 100644 index 0000000..5155e2d Binary files /dev/null and b/android-app/app/src/main/res/font/monospace.ttf differ diff --git a/android-app/app/src/main/res/layout/activity_galleryfullscreenviewer.xml b/android-app/app/src/main/res/layout/activity_galleryfullscreenviewer.xml new file mode 100644 index 0000000..5541180 --- /dev/null +++ b/android-app/app/src/main/res/layout/activity_galleryfullscreenviewer.xml @@ -0,0 +1,31 @@ + + + + + + \ No newline at end of file diff --git a/android-app/app/src/main/res/layout/activity_login.xml b/android-app/app/src/main/res/layout/activity_login.xml new file mode 100644 index 0000000..1216147 --- /dev/null +++ b/android-app/app/src/main/res/layout/activity_login.xml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + +