Android app

This commit is contained in:
2026-01-31 17:28:29 +01:00
parent db5f030c12
commit e1c752fcf8
203 changed files with 7626 additions and 0 deletions

1
android-app/app/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

View File

@@ -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'
}

21
android-app/app/proguard-rules.pro vendored Normal file
View File

@@ -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

Binary file not shown.

View File

@@ -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)
}
}

View File

@@ -0,0 +1,114 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Permissions -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-feature android:name="android.hardware.camera" />
<uses-feature android:name="android.hardware.camera.autofocus" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<!-- Queries -->
<queries>
<!-- To open system camera image capture screen -->
<intent>
<action android:name="android.media.action.IMAGE_CAPTURE" />
</intent>
<!-- To see if google maps is installed, as it needs a specific intent Uri) -->
<package android:name="com.google.android.apps.maps" />
</queries>
<!-- App -->
<application
android:name=".DeRApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:hardwareAccelerated="true"
android:networkSecurityConfig="@xml/network_security_config">
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="it.danieleverducci.openweddingapp.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths">
</meta-data>
</provider>
<activity
android:name=".ui.LoginActivity"
android:label="@string/app_name"
android:theme="@style/AppTheme.NoActionBar"
android:launchMode="singleTask"
android:screenOrientation="portrait"
tools:replace="screenOrientation"
android:exported="true">
<!-- Open by launcher -->
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Open by qrcode -->
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="http" android:host="mysite.com"/>
<data android:scheme="https"/>
</intent-filter>
</activity>
<activity
android:name=".MainActivity"
android:label="@string/app_name"
android:theme="@style/AppTheme.NoActionBar"
android:launchMode="singleTask"
android:screenOrientation="portrait"
tools:replace="screenOrientation"
android:exported="true">
</activity>
<activity
android:name=".ShareActivity"
android:label="@string/app_name"
android:theme="@style/AppTheme.NoActionBar"
android:launchMode="singleTask"
android:screenOrientation="portrait"
tools:replace="screenOrientation"
android:exported="true">
<!-- Receive single image share intent -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
<!-- Receive multiple images share intent -->
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
</activity>
<activity
android:name="com.journeyapps.barcodescanner.CaptureActivity"
android:launchMode="singleTask"
android:screenOrientation="portrait"
tools:replace="screenOrientation"/>
<activity
android:name=".ui.gallery.GalleryFullscreenViewerActivity"
android:theme="@style/Theme.AppCompat.Light.NoActionBar"
android:launchMode="singleTask"
android:screenOrientation="fullSensor"
tools:replace="screenOrientation"/>
</application>
</manifest>

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View File

@@ -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
}

View File

@@ -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<NotificationWorker>(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<TextView>(R.id.nav_header_user_name).text = "${user.name} ${user.surname}"
if (user.picture != null && user.picture.isNotEmpty()) {
val userBadge = headerView.findViewById<ImageView>(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<TextView>(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<Uri>()
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<View>(R.id.fab_take_photo), show)
animateFab(findViewById<View>(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<RemoteSettings> {
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()
}
}

View File

@@ -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<Uri>()
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<Parcelable>(Intent.EXTRA_STREAM) as? Uri)?.let {
uploadQueue.addLast(it)
upload()
}
}
intent?.action == Intent.ACTION_SEND_MULTIPLE -> {
intent.getParcelableArrayListExtra<Parcelable>(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)
}
}

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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<String, LocalizedGenericDynamicContent>()
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}"
}
}
}

View File

@@ -0,0 +1,7 @@
package it.danieleverducci.openweddingapp.entities
import org.json.JSONObject
interface Jsonable {
fun toJson(): JSONObject
}

View File

@@ -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
}
}

View File

@@ -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<String, LocalizedLocation>()
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<Double>
/**
* 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"
)
}
}

View File

@@ -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")
}
}

View File

@@ -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")
}
}

View File

@@ -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
}
}

View File

@@ -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<WifiNetwork>
@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
}
}

View File

@@ -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<TablePeople>
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")
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}

View File

@@ -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<T: Jsonable> (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<T>) {
val url = "${baseUrl}/read.php?page=${page}"
val request = AuthenticatedJsonObjectRequest(
token,
Request.Method.GET,
url,
null,
Response.Listener<JSONObject> {
val records = it.getJSONArray("records")
val page = it.getInt("page")
val more = it.getBoolean("more")
val items = ArrayList<T>(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<T>) {
val url = "${baseUrl}/get.php?id=${id}"
val request = AuthenticatedJsonObjectRequest(
token,
Request.Method.GET,
url,
null,
Response.Listener<JSONObject> {
val item = newItem(it)
listener.OnItemObtained(item)
},
Response.ErrorListener {
listener.OnError(it)
})
queue.add(request)
}
open fun postItem(baseUrl: String, listener: OnItemPostedListener<T>, item: T) {
val request = AuthenticatedJsonObjectRequest(
token,
Request.Method.POST,
"${baseUrl}/create.php",
item.toJson(),
Response.Listener<JSONObject> {
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<T>, id: Int) {
val request = AuthenticatedJsonObjectRequest(
token,
Request.Method.DELETE,
"${baseUrl}/delete.php?id=${id}",
null,
Response.Listener<JSONObject> {
listener.OnItemDeleted()
},
Response.ErrorListener {
Log.e(TAG, "Unable to delete item: ${it}")
listener.OnError(it)
}
)
queue.add(request)
}
interface OnItemListObtainedListener<T> {
fun OnItemListObtained(items: ArrayList<T>?, page: Int, more: Boolean)
fun OnError(error: VolleyError)
}
interface OnItemObtainedListener<T> {
fun OnItemObtained(item: T)
fun OnError(error: VolleyError)
}
interface OnItemPostedListener<T> {
fun OnItemPosted(item: T)
fun OnError(error: VolleyError)
}
interface OnItemDeletedListener<T> {
fun OnItemDeleted()
fun OnError(error: VolleyError)
}
}

View File

@@ -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<JSONObject>?,
errorListener: Response.ErrorListener?
) : JsonObjectRequest(method, url, jsonRequest, listener, errorListener) {
override fun getHeaders(): MutableMap<String, String> {
val h = HashMap<String, String>()
h.put("Authentication", token)
h.put("Accept", "application/json")
return h
}
}

View File

@@ -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 <input type="file" name="..." />
* @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<String> finish() throws IOException {
List<String> response = new ArrayList<String>();
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;
}
}

View File

@@ -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<GalleryItem>(context, token) {
companion object {
val TAG = "GalleryItemNet"
}
fun getList(page: Int, listener: OnItemListObtainedListener<GalleryItem>) {
val url = "${Config.API_BASE_URL}${Config.API_ENDPOINT_GALLERY_ITEM}"
super.getList(url, page, listener)
}
fun postItem(listener: OnItemPostedListener<GalleryItem>, 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)
}
}

View File

@@ -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<GenericDynamicContent>(context) {
companion object {
val TAG = "GenericDynamicContentNet"
}
override fun getItem(relUrl: String, listener: OnItemObtainedListener<GenericDynamicContent>) {
val url = "${Config.API_BASE_URL}${relUrl}"
super.getItem(url, listener)
}
override fun newItem(jo: JSONObject): GenericDynamicContent {
return GenericDynamicContent(jo)
}
}

View File

@@ -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<Like>(context, token) {
companion object {
val TAG = "LikeNet"
val url = "${Config.API_BASE_URL}${Config.API_ENDPOINT_LIKE}"
}
fun postItem(listener: OnItemPostedListener<Like>, item: Like) {
super.postItem(url, listener, item)
}
fun deleteItem(listener: OnItemDeletedListener<Like>, id: Int) {
super.deleteItem(url, listener, id)
}
override fun newItem(jo: JSONObject): Like {
return Like(jo)
}
}

View File

@@ -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<Location>(context) {
companion object {
val TAG = "LocationNet"
val url = "${Config.API_BASE_URL}${Config.STATIC_ENDPOINT_LOCATION}"
}
fun getItem(listener: OnItemObtainedListener<Location>) {
super.getItem(url, listener)
}
override fun newItem(jo: JSONObject): Location {
return Location(jo)
}
}

View File

@@ -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<NtfyMessage>()
// 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<NtfyMessage>)
fun OnError(error: VolleyError)
}
}

View File

@@ -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)
}
}

View File

@@ -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<Presence>(context, token) {
companion object {
val TAG = "PresenceNet"
}
fun postItem(listener: OnItemPostedListener<Presence>, item: Presence) {
val url = "${Config.API_BASE_URL}${Config.API_ENDPOINT_PRESENCE}"
super.postItem(url, listener, item)
}
fun getList(listener: OnItemListObtainedListener<Presence>) {
val url = "${Config.API_BASE_URL}${Config.API_ENDPOINT_PRESENCE}"
super.getList(url, 1, listener)
}
override fun newItem(jo: JSONObject): Presence {
return Presence(jo)
}
}

View File

@@ -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<RemoteSettings>(context) {
companion object {
val TAG = "RemoteSettingsNet"
val url = "${Config.API_BASE_URL}${Config.STATIC_ENDPOINT_SETTINGS}"
}
fun getItem(listener: OnItemObtainedListener<RemoteSettings>) {
super.getItem(url, listener)
}
override fun newItem(jo: JSONObject): RemoteSettings {
return RemoteSettings(jo)
}
}

View File

@@ -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<T: Jsonable> (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<T>) {
val request = JsonObjectRequest(
Request.Method.GET,
url,
null,
{
val item = newItem(it)
listener.OnItemObtained(item)
},
{
listener.OnError(it)
})
queue.add(request)
}
interface OnItemObtainedListener<T> {
fun OnItemObtained(item: T)
fun OnError(error: VolleyError)
}
}

View File

@@ -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<Table>(context, token) {
companion object {
val TAG = "TableNet"
}
fun getItem(listener: OnItemObtainedListener<Table>) {
val url = "${Config.API_BASE_URL}${Config.API_ENDPOINT_TABLE}"
super.getItem(url, 0, listener)
}
override fun newItem(jo: JSONObject): Table {
return Table(jo)
}
}

View File

@@ -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<JSONObject> {
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?)
}
}

View File

@@ -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<File, Void, JSONObject?>() {
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")
}
}
}

View File

@@ -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<GenericDynamicContent> {
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
}

View File

@@ -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<EditText>(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<View>(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<View>(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<String>, 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
}
}
}
}

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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<GalleryItem>()
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<RecyclerView>(R.id.gallery_recyclerview)
swipeRefreshLayout = root.findViewById<SwipeRefreshLayout>(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<Like> {
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<Like> {
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<GalleryItem> {
override fun OnItemListObtained(
items: ArrayList<GalleryItem>?,
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)
}
}

View File

@@ -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<PhotoView>(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<View>(R.id.close_button).setOnClickListener {
finish()
}
}
}

View File

@@ -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<GalleryItem>):
RecyclerView.Adapter<GalleryRecyclerAdapter.ViewHolder>() {
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<ImageView>(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<ImageView>(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<ProgressBar>(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<ImageView>(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<ProgressBar>(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()
}
}

View File

@@ -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
}
}

View File

@@ -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<TextView>(R.id.privacy_content)?.text =
context?.resources?.openRawResource(R.raw.privacy)?.bufferedReader().use {
it?.readText()
}
}
}

View File

@@ -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<Location> {
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)
}
}

View File

@@ -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<String>,
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()
}
}
}
}

View File

@@ -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<Presence> {
override fun OnItemListObtained(
items: ArrayList<Presence>?,
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<Presence> {
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
}
}

View File

@@ -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<Table> {
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
}
}

View File

@@ -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
}
}

View File

@@ -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
)
}
}
}

View File

@@ -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<ResolveInfo> = 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()
}
}
}

View File

@@ -0,0 +1,9 @@
package it.danieleverducci.openweddingapp.utils
class NotificationPlanner {
companion object {
fun plan () {
}
}
}

View File

@@ -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++
}
}

View File

@@ -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<NtfyMessage>) {
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}")
}
})
}
}

View File

@@ -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
}
}
}

View File

@@ -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<GalleryItem>) {
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<GalleryItem>? {
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<GalleryItem>(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)
}
}
}

View File

@@ -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)
}
}

View File

@@ -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<WifiNetwork>) {
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<WifiNetwork>) {
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<WifiNetworkSuggestion> = 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<WifiNetworkSuggestion>())
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
}
}
}
}
}

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
<rotate
android:fromDegrees="0"
android:toDegrees="365"
android:pivotX="50%"
android:pivotY="50%"
android:duration="800" />
</set>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
android:startOffset="500">
<translate android:fromXDelta="50%p" android:toXDelta="0"
android:duration="@android:integer/config_mediumAnimTime"/>
<alpha android:fromAlpha="0.0" android:toAlpha="1.0"
android:duration="@android:integer/config_mediumAnimTime" />
</set>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
android:startOffset="500">
<translate android:fromXDelta="0" android:toXDelta="50%p"
android:duration="@android:integer/config_mediumAnimTime"/>
<alpha android:fromAlpha="1.0" android:toAlpha="0.0"
android:duration="@android:integer/config_mediumAnimTime" />
</set>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M22,16V4c0,-1.1 -0.9,-2 -2,-2H8c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2zm-11,-4l2.03,2.71L16,11l4,5H8l3,-4zM2,6v14c0,1.1 0.9,2 2,2h14v-2H4V6H2z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M22.7,19l-9.1,-9.1c0.9,-2.3 0.4,-5 -1.5,-6.9 -2,-2 -5,-2.4 -7.4,-1.3L9,6 6,9 1.6,4.7C0.4,7.1 0.9,10.1 2.9,12.1c1.9,1.9 4.6,2.4 6.9,1.5l9.1,9.1c0.4,0.4 1,0.4 1.4,0l2.3,-2.3c0.5,-0.4 0.5,-1.1 0.1,-1.4z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M2.01,21L23,12 2.01,3 2,10l15,2 -15,2z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M18,16.08c-0.76,0 -1.44,0.3 -1.96,0.77L8.91,12.7c0.05,-0.23 0.09,-0.46 0.09,-0.7s-0.04,-0.47 -0.09,-0.7l7.05,-4.11c0.54,0.5 1.25,0.81 2.04,0.81 1.66,0 3,-1.34 3,-3s-1.34,-3 -3,-3 -3,1.34 -3,3c0,0.24 0.04,0.47 0.09,0.7L8.04,9.81C7.5,9.31 6.79,9 6,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3c0.79,0 1.5,-0.31 2.04,-0.81l7.12,4.16c-0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.61 1.31,2.92 2.92,2.92 1.61,0 2.92,-1.31 2.92,-2.92s-1.31,-2.92 -2.92,-2.92z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M4,6H2v14c0,1.1 0.9,2 2,2h14v-2H4V6zm16,-4H8c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2V4c0,-1.1 -0.9,-2 -2,-2zm-8,12.5v-9l6,4.5 -6,4.5z" />
</vector>

View File

@@ -0,0 +1,5 @@
<vector android:height="36dp" android:tint="#0082C9"
android:viewportHeight="24" android:viewportWidth="24"
android:width="36dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M12,2C8.13,2 5,5.13 5,9c0,5.25 7,13 7,13s7,-7.75 7,-13c0,-3.87 -3.13,-7 -7,-7zM12,11.5c-1.38,0 -2.5,-1.12 -2.5,-2.5s1.12,-2.5 2.5,-2.5 2.5,1.12 2.5,2.5 -1.12,2.5 -2.5,2.5z"/>
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<solid android:color="@color/gallery_background_item"/>
<corners android:bottomRightRadius="7dp"
android:bottomLeftRadius="7dp"
android:topLeftRadius="7dp"
android:topRightRadius="7dp" />
</shape>
</item>
</selector>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<solid android:color="@color/white_translucent"/>
<corners android:bottomRightRadius="15dp"
android:bottomLeftRadius="15dp"
android:topLeftRadius="15dp"
android:topRightRadius="15dp" />
</shape>
</item>
</selector>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<size android:height="50dp"/>
<solid android:color="@color/color_button_primary"/>
<corners android:bottomRightRadius="25dp"
android:bottomLeftRadius="25dp"
android:topLeftRadius="25dp"
android:topRightRadius="25dp" />
</shape>
</item>
</selector>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<size android:height="50dp"/>
<solid android:color="@color/color_button_primary_pressed"/>
<corners android:bottomRightRadius="25dp"
android:bottomLeftRadius="25dp"
android:topLeftRadius="25dp"
android:topRightRadius="25dp" />
</shape>
</item>
</selector>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape android:shape="oval" xmlns:android="http://schemas.android.com/apk/res/android">
<solid
android:color="@color/color_button_primary"/>
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape android:shape="oval" xmlns:android="http://schemas.android.com/apk/res/android">
<solid
android:color="@color/color_button_primary_pressed"/>
</shape>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<size android:height="50dp"/>
<solid android:color="@color/color_button_secondary"/>
<corners android:bottomRightRadius="25dp"
android:bottomLeftRadius="25dp"
android:topLeftRadius="25dp"
android:topRightRadius="25dp" />
</shape>
</item>
</selector>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<size android:height="50dp"/>
<solid android:color="@color/color_button_secondary_pressed"/>
<corners android:bottomRightRadius="25dp"
android:bottomLeftRadius="25dp"
android:topLeftRadius="25dp"
android:topRightRadius="25dp" />
</shape>
</item>
</selector>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/button_background_pressed" android:state_pressed="true"/>
<item android:drawable="@drawable/button_background"/>
</selector>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/button_background_round_pressed" android:state_pressed="true"/>
<item android:drawable="@drawable/button_background_round"/>
</selector>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/button_background_secondary_pressed" android:state_pressed="true"/>
<item android:drawable="@drawable/button_background_secondary"/>
</selector>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<size android:height="50dp"/>
<solid android:color="@color/color_edittext_background"/>
<corners android:bottomRightRadius="15dp"
android:bottomLeftRadius="15dp"
android:topLeftRadius="15dp"
android:topRightRadius="15dp" />
</shape>
</item>
</selector>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape android:shape="oval" xmlns:android="http://schemas.android.com/apk/res/android">
<solid
android:color="@color/gallery_liked"/>
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape android:shape="oval" xmlns:android="http://schemas.android.com/apk/res/android">
<solid
android:color="@color/gallery_not_liked"/>
</shape>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M3,4L3,1h2v3h3v2L5,6v3L3,9L3,6L0,6L0,4h3zM6,10L6,7h3L9,4h7l1.83,2L21,6c1.1,0 2,0.9 2,2v12c0,1.1 -0.9,2 -2,2L5,22c-1.1,0 -2,-0.9 -2,-2L3,10h3zM13,19c2.76,0 5,-2.24 5,-5s-2.24,-5 -5,-5 -5,2.24 -5,5 2.24,5 5,5zM9.8,14c0,1.77 1.43,3.2 3.2,3.2s3.2,-1.43 3.2,-3.2 -1.43,-3.2 -3.2,-3.2 -3.2,1.43 -3.2,3.2z"/>
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z"/>
</vector>

View File

@@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M16,1L4,1c-1.1,0 -2,0.9 -2,2v14h2L4,3h12L16,1zM19,5L8,5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h11c1.1,0 2,-0.9 2,-2L21,7c0,-1.1 -0.9,-2 -2,-2zM19,21L8,21L8,7h11v14z"/>
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M18.7,12.4c-0.28,-0.16 -0.57,-0.29 -0.86,-0.4 0.29,-0.11 0.58,-0.24 0.86,-0.4 1.92,-1.11 2.99,-3.12 3,-5.19 -1.79,-1.03 -4.07,-1.11 -6,0 -0.28,0.16 -0.54,0.35 -0.78,0.54 0.05,-0.31 0.08,-0.63 0.08,-0.95 0,-2.22 -1.21,-4.15 -3,-5.19C10.21,1.85 9,3.78 9,6c0,0.32 0.03,0.64 0.08,0.95 -0.24,-0.2 -0.5,-0.39 -0.78,-0.55 -1.92,-1.11 -4.2,-1.03 -6,0 0,2.07 1.07,4.08 3,5.19 0.28,0.16 0.57,0.29 0.86,0.4 -0.29,0.11 -0.58,0.24 -0.86,0.4 -1.92,1.11 -2.99,3.12 -3,5.19 1.79,1.03 4.07,1.11 6,0 0.28,-0.16 0.54,-0.35 0.78,-0.54 -0.05,0.32 -0.08,0.64 -0.08,0.96 0,2.22 1.21,4.15 3,5.19 1.79,-1.04 3,-2.97 3,-5.19 0,-0.32 -0.03,-0.64 -0.08,-0.95 0.24,0.2 0.5,0.38 0.78,0.54 1.92,1.11 4.2,1.03 6,0 -0.01,-2.07 -1.08,-4.08 -3,-5.19zM12,16c-2.21,0 -4,-1.79 -4,-4s1.79,-4 4,-4 4,1.79 4,4 -1.79,4 -4,4z"/>
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M21.71,11.29l-9,-9c-0.39,-0.39 -1.02,-0.39 -1.41,0l-9,9c-0.39,0.39 -0.39,1.02 0,1.41l9,9c0.39,0.39 1.02,0.39 1.41,0l9,-9c0.39,-0.38 0.39,-1.01 0,-1.41zM14,14.5V12h-4v3H8v-4c0,-0.55 0.45,-1 1,-1h5V7.5l3.5,3.5 -3.5,3.5z"/>
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M9,16.2L4.8,12l-1.4,1.4L9,19 21,7l-1.4,-1.4L9,16.2z"/>
</vector>

View File

@@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M19,9h-4V3H9v6H5l7,7 7,-7zM5,18v2h14v-2H5z"/>
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M 16.5 3 c -0.96 0 -1.5 0 -2.39 0.249 C 12.004 4.262 12 6 12 6 C 12 6 11.826 2.744 7.471 2.954 C 4.42 3 2 5.42 2 8.5 c 0 4.13 4.16 7.18 10 12.5 c 5.47 -4.94 10 -8.26 10 -12.5 C 22 5.42 19.58 3 16.5 3 z"/>
</vector>

View File

@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal"
android:autoMirrored="true">
<path
android:fillColor="@android:color/white"
android:pathData="M17,7l-1.41,1.41L18.17,11H8v2h10.17l-2.58,2.58L17,17l5,-5zM4,5h8V3H4c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h8v-2H4V5z"/>
</vector>

View File

@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="36dp"
android:height="36dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M18,8h2V4h-2V8zM15.51,22H2.49C2.22,22 2,21.78 2,21.5V20h14v1.5C16,21.78 15.78,22 15.51,22zM18,15.89l-0.4,-0.42c-1.02,-1.08 -1.6,-2.52 -1.6,-4V2h6v9.51c0,1.46 -0.54,2.87 -1.53,3.94L20,15.97V20h2v2h-4V15.89zM7,16v-2h4v2h4.5c0.28,0 0.5,0.22 0.5,0.5v1c0,0.28 -0.22,0.5 -0.5,0.5h-13C2.22,18 2,17.78 2,17.5v-1C2,16.22 2.22,16 2.5,16H7z"
android:fillType="evenOdd"/>
</vector>

Some files were not shown because too many files have changed in this diff Show More