Implement complete Android app code
- Add models: Note, SyncStatus - Add storage: NotesStorage for local file system - Add sync: WebDavSyncService, SyncWorker, WifiSyncReceiver - Add UI: MainActivity, NoteEditorActivity, SettingsActivity - Add adapters: NotesAdapter - Add utils: Constants, DeviceIdGenerator, Extensions, NotificationHelper - Add layouts: activity_main, activity_editor, activity_settings, item_note - Add menus and strings
This commit is contained in:
@@ -1,20 +1,160 @@
|
||||
package dev.dettmer.simplenotes
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.appbar.MaterialToolbar
|
||||
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
import dev.dettmer.simplenotes.adapters.NotesAdapter
|
||||
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||
import dev.dettmer.simplenotes.utils.NotificationHelper
|
||||
import dev.dettmer.simplenotes.utils.showToast
|
||||
import android.widget.TextView
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var recyclerViewNotes: RecyclerView
|
||||
private lateinit var textViewEmpty: TextView
|
||||
private lateinit var fabAddNote: FloatingActionButton
|
||||
private lateinit var toolbar: MaterialToolbar
|
||||
|
||||
private lateinit var adapter: NotesAdapter
|
||||
private val storage by lazy { NotesStorage(this) }
|
||||
|
||||
companion object {
|
||||
private const val REQUEST_NOTIFICATION_PERMISSION = 1001
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContentView(R.layout.activity_main)
|
||||
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
|
||||
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
|
||||
insets
|
||||
|
||||
// Notification Channel erstellen
|
||||
NotificationHelper.createNotificationChannel(this)
|
||||
|
||||
// Permission für Notifications (Android 13+)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
requestNotificationPermission()
|
||||
}
|
||||
|
||||
findViews()
|
||||
setupToolbar()
|
||||
setupRecyclerView()
|
||||
setupFab()
|
||||
|
||||
loadNotes()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
loadNotes()
|
||||
}
|
||||
|
||||
private fun findViews() {
|
||||
recyclerViewNotes = findViewById(R.id.recyclerViewNotes)
|
||||
textViewEmpty = findViewById(R.id.textViewEmpty)
|
||||
fabAddNote = findViewById(R.id.fabAddNote)
|
||||
toolbar = findViewById(R.id.toolbar)
|
||||
}
|
||||
|
||||
private fun setupToolbar() {
|
||||
setSupportActionBar(toolbar)
|
||||
}
|
||||
|
||||
private fun setupRecyclerView() {
|
||||
adapter = NotesAdapter { note ->
|
||||
openNoteEditor(note.id)
|
||||
}
|
||||
recyclerViewNotes.adapter = adapter
|
||||
recyclerViewNotes.layoutManager = LinearLayoutManager(this)
|
||||
}
|
||||
|
||||
private fun setupFab() {
|
||||
fabAddNote.setOnClickListener {
|
||||
openNoteEditor(null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadNotes() {
|
||||
val notes = storage.loadAllNotes()
|
||||
adapter.submitList(notes)
|
||||
|
||||
// Empty state
|
||||
textViewEmpty.visibility = if (notes.isEmpty()) {
|
||||
android.view.View.VISIBLE
|
||||
} else {
|
||||
android.view.View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun openNoteEditor(noteId: String?) {
|
||||
val intent = Intent(this, NoteEditorActivity::class.java)
|
||||
noteId?.let {
|
||||
intent.putExtra(NoteEditorActivity.EXTRA_NOTE_ID, it)
|
||||
}
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
private fun openSettings() {
|
||||
startActivity(Intent(this, SettingsActivity::class.java))
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.menu_main, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
R.id.action_settings -> {
|
||||
openSettings()
|
||||
true
|
||||
}
|
||||
R.id.action_sync -> {
|
||||
// Manual sync trigger could be added here
|
||||
showToast("Sync wird in den Einstellungen gestartet")
|
||||
true
|
||||
}
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestNotificationPermission() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
|
||||
!= PackageManager.PERMISSION_GRANTED) {
|
||||
requestPermissions(
|
||||
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||
REQUEST_NOTIFICATION_PERMISSION
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int,
|
||||
permissions: Array<out String>,
|
||||
grantResults: IntArray
|
||||
) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
|
||||
when (requestCode) {
|
||||
REQUEST_NOTIFICATION_PERMISSION -> {
|
||||
if (grantResults.isNotEmpty() &&
|
||||
grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||
showToast("Benachrichtigungen aktiviert")
|
||||
} else {
|
||||
showToast("Benachrichtigungen deaktiviert. " +
|
||||
"Du kannst sie in den Einstellungen aktivieren.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package dev.dettmer.simplenotes
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import dev.dettmer.simplenotes.models.Note
|
||||
import dev.dettmer.simplenotes.models.SyncStatus
|
||||
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||
import dev.dettmer.simplenotes.utils.DeviceIdGenerator
|
||||
import dev.dettmer.simplenotes.utils.showToast
|
||||
import com.google.android.material.textfield.TextInputEditText
|
||||
|
||||
class NoteEditorActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var editTextTitle: TextInputEditText
|
||||
private lateinit var editTextContent: TextInputEditText
|
||||
private lateinit var storage: NotesStorage
|
||||
|
||||
private var existingNote: Note? = null
|
||||
|
||||
companion object {
|
||||
const val EXTRA_NOTE_ID = "extra_note_id"
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_editor)
|
||||
|
||||
storage = NotesStorage(this)
|
||||
|
||||
// Setup toolbar
|
||||
supportActionBar?.apply {
|
||||
setDisplayHomeAsUpEnabled(true)
|
||||
setHomeAsUpIndicator(android.R.drawable.ic_menu_close_clear_cancel)
|
||||
}
|
||||
|
||||
// Find views
|
||||
editTextTitle = findViewById(R.id.editTextTitle)
|
||||
editTextContent = findViewById(R.id.editTextContent)
|
||||
|
||||
// Load existing note if editing
|
||||
val noteId = intent.getStringExtra(EXTRA_NOTE_ID)
|
||||
if (noteId != null) {
|
||||
existingNote = storage.loadNote(noteId)
|
||||
existingNote?.let {
|
||||
editTextTitle.setText(it.title)
|
||||
editTextContent.setText(it.content)
|
||||
supportActionBar?.title = "Notiz bearbeiten"
|
||||
}
|
||||
} else {
|
||||
supportActionBar?.title = "Neue Notiz"
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.menu_editor, menu)
|
||||
|
||||
// Show delete only for existing notes
|
||||
menu.findItem(R.id.action_delete)?.isVisible = existingNote != null
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
android.R.id.home -> {
|
||||
finish()
|
||||
true
|
||||
}
|
||||
R.id.action_save -> {
|
||||
saveNote()
|
||||
true
|
||||
}
|
||||
R.id.action_delete -> {
|
||||
confirmDelete()
|
||||
true
|
||||
}
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveNote() {
|
||||
val title = editTextTitle.text?.toString()?.trim() ?: ""
|
||||
val content = editTextContent.text?.toString()?.trim() ?: ""
|
||||
|
||||
if (title.isEmpty() && content.isEmpty()) {
|
||||
showToast("Titel oder Inhalt darf nicht leer sein")
|
||||
return
|
||||
}
|
||||
|
||||
val note = if (existingNote != null) {
|
||||
// Update existing note
|
||||
existingNote!!.copy(
|
||||
title = title,
|
||||
content = content,
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
syncStatus = SyncStatus.PENDING
|
||||
)
|
||||
} else {
|
||||
// Create new note
|
||||
Note(
|
||||
title = title,
|
||||
content = content,
|
||||
deviceId = DeviceIdGenerator.getDeviceId(this),
|
||||
syncStatus = SyncStatus.LOCAL_ONLY
|
||||
)
|
||||
}
|
||||
|
||||
storage.saveNote(note)
|
||||
showToast("Notiz gespeichert")
|
||||
finish()
|
||||
}
|
||||
|
||||
private fun confirmDelete() {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle("Notiz löschen?")
|
||||
.setMessage("Diese Aktion kann nicht rückgängig gemacht werden.")
|
||||
.setPositiveButton("Löschen") { _, _ ->
|
||||
deleteNote()
|
||||
}
|
||||
.setNegativeButton("Abbrechen", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun deleteNote() {
|
||||
existingNote?.let {
|
||||
storage.deleteNote(it.id)
|
||||
showToast("Notiz gelöscht")
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package dev.dettmer.simplenotes
|
||||
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.Bundle
|
||||
import android.view.MenuItem
|
||||
import android.widget.Button
|
||||
import android.widget.EditText
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.widget.SwitchCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import dev.dettmer.simplenotes.sync.WebDavSyncService
|
||||
import dev.dettmer.simplenotes.utils.Constants
|
||||
import dev.dettmer.simplenotes.utils.showToast
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class SettingsActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var editTextServerUrl: EditText
|
||||
private lateinit var editTextUsername: EditText
|
||||
private lateinit var editTextPassword: EditText
|
||||
private lateinit var editTextHomeSSID: EditText
|
||||
private lateinit var switchAutoSync: SwitchCompat
|
||||
private lateinit var buttonTestConnection: Button
|
||||
private lateinit var buttonSyncNow: Button
|
||||
private lateinit var buttonDetectSSID: Button
|
||||
|
||||
private val prefs by lazy {
|
||||
getSharedPreferences(Constants.PREFS_NAME, MODE_PRIVATE)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_settings)
|
||||
|
||||
supportActionBar?.apply {
|
||||
setDisplayHomeAsUpEnabled(true)
|
||||
title = "Einstellungen"
|
||||
}
|
||||
|
||||
findViews()
|
||||
loadSettings()
|
||||
setupListeners()
|
||||
}
|
||||
|
||||
private fun findViews() {
|
||||
editTextServerUrl = findViewById(R.id.editTextServerUrl)
|
||||
editTextUsername = findViewById(R.id.editTextUsername)
|
||||
editTextPassword = findViewById(R.id.editTextPassword)
|
||||
editTextHomeSSID = findViewById(R.id.editTextHomeSSID)
|
||||
switchAutoSync = findViewById(R.id.switchAutoSync)
|
||||
buttonTestConnection = findViewById(R.id.buttonTestConnection)
|
||||
buttonSyncNow = findViewById(R.id.buttonSyncNow)
|
||||
buttonDetectSSID = findViewById(R.id.buttonDetectSSID)
|
||||
}
|
||||
|
||||
private fun loadSettings() {
|
||||
editTextServerUrl.setText(prefs.getString(Constants.KEY_SERVER_URL, ""))
|
||||
editTextUsername.setText(prefs.getString(Constants.KEY_USERNAME, ""))
|
||||
editTextPassword.setText(prefs.getString(Constants.KEY_PASSWORD, ""))
|
||||
editTextHomeSSID.setText(prefs.getString(Constants.KEY_HOME_SSID, ""))
|
||||
switchAutoSync.isChecked = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false)
|
||||
}
|
||||
|
||||
private fun setupListeners() {
|
||||
buttonTestConnection.setOnClickListener {
|
||||
saveSettings()
|
||||
testConnection()
|
||||
}
|
||||
|
||||
buttonSyncNow.setOnClickListener {
|
||||
saveSettings()
|
||||
syncNow()
|
||||
}
|
||||
|
||||
buttonDetectSSID.setOnClickListener {
|
||||
detectCurrentSSID()
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveSettings() {
|
||||
prefs.edit().apply {
|
||||
putString(Constants.KEY_SERVER_URL, editTextServerUrl.text.toString().trim())
|
||||
putString(Constants.KEY_USERNAME, editTextUsername.text.toString().trim())
|
||||
putString(Constants.KEY_PASSWORD, editTextPassword.text.toString().trim())
|
||||
putString(Constants.KEY_HOME_SSID, editTextHomeSSID.text.toString().trim())
|
||||
putBoolean(Constants.KEY_AUTO_SYNC, switchAutoSync.isChecked)
|
||||
apply()
|
||||
}
|
||||
}
|
||||
|
||||
private fun testConnection() {
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
showToast("Teste Verbindung...")
|
||||
val syncService = WebDavSyncService(this@SettingsActivity)
|
||||
val result = syncService.syncNotes()
|
||||
|
||||
if (result.isSuccess) {
|
||||
showToast("Verbindung erfolgreich! ${result.syncedCount} Notizen synchronisiert")
|
||||
} else {
|
||||
showToast("Verbindung fehlgeschlagen: ${result.errorMessage}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
showToast("Fehler: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun syncNow() {
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
showToast("Synchronisiere...")
|
||||
val syncService = WebDavSyncService(this@SettingsActivity)
|
||||
val result = syncService.syncNotes()
|
||||
|
||||
if (result.isSuccess) {
|
||||
if (result.hasConflicts) {
|
||||
showToast("Sync abgeschlossen. ${result.conflictCount} Konflikte erkannt!")
|
||||
} else {
|
||||
showToast("Erfolgreich! ${result.syncedCount} Notizen synchronisiert")
|
||||
}
|
||||
} else {
|
||||
showToast("Sync fehlgeschlagen: ${result.errorMessage}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
showToast("Fehler: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun detectCurrentSSID() {
|
||||
val wifiManager = applicationContext.getSystemService(WIFI_SERVICE) as WifiManager
|
||||
val wifiInfo = wifiManager.connectionInfo
|
||||
val ssid = wifiInfo.ssid.replace("\"", "")
|
||||
|
||||
if (ssid.isNotEmpty() && ssid != "<unknown ssid>") {
|
||||
editTextHomeSSID.setText(ssid)
|
||||
showToast("SSID erkannt: $ssid")
|
||||
} else {
|
||||
showToast("Nicht mit WLAN verbunden")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
android.R.id.home -> {
|
||||
saveSettings()
|
||||
finish()
|
||||
true
|
||||
}
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
saveSettings()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package dev.dettmer.simplenotes.adapters
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import dev.dettmer.simplenotes.R
|
||||
import dev.dettmer.simplenotes.models.Note
|
||||
import dev.dettmer.simplenotes.models.SyncStatus
|
||||
import dev.dettmer.simplenotes.utils.toReadableTime
|
||||
import dev.dettmer.simplenotes.utils.truncate
|
||||
|
||||
class NotesAdapter(
|
||||
private val onNoteClick: (Note) -> Unit
|
||||
) : ListAdapter<Note, NotesAdapter.NoteViewHolder>(NoteDiffCallback()) {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NoteViewHolder {
|
||||
val view = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.item_note, parent, false)
|
||||
return NoteViewHolder(view)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: NoteViewHolder, position: Int) {
|
||||
holder.bind(getItem(position))
|
||||
}
|
||||
|
||||
inner class NoteViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
private val textViewTitle: TextView = itemView.findViewById(R.id.textViewTitle)
|
||||
private val textViewContent: TextView = itemView.findViewById(R.id.textViewContent)
|
||||
private val textViewTimestamp: TextView = itemView.findViewById(R.id.textViewTimestamp)
|
||||
private val imageViewSyncStatus: ImageView = itemView.findViewById(R.id.imageViewSyncStatus)
|
||||
|
||||
fun bind(note: Note) {
|
||||
textViewTitle.text = note.title.ifEmpty { "Ohne Titel" }
|
||||
textViewContent.text = note.content.truncate(100)
|
||||
textViewTimestamp.text = note.updatedAt.toReadableTime()
|
||||
|
||||
// Sync status icon
|
||||
val syncIcon = when (note.syncStatus) {
|
||||
SyncStatus.SYNCED -> android.R.drawable.ic_menu_upload
|
||||
SyncStatus.PENDING -> android.R.drawable.ic_popup_sync
|
||||
SyncStatus.CONFLICT -> android.R.drawable.ic_dialog_alert
|
||||
SyncStatus.LOCAL_ONLY -> android.R.drawable.ic_menu_save
|
||||
}
|
||||
imageViewSyncStatus.setImageResource(syncIcon)
|
||||
|
||||
itemView.setOnClickListener {
|
||||
onNoteClick(note)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class NoteDiffCallback : DiffUtil.ItemCallback<Note>() {
|
||||
override fun areItemsTheSame(oldItem: Note, newItem: Note): Boolean {
|
||||
return oldItem.id == newItem.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: Note, newItem: Note): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package dev.dettmer.simplenotes.models
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
data class Note(
|
||||
val id: String = UUID.randomUUID().toString(),
|
||||
val title: String,
|
||||
val content: String,
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
val updatedAt: Long = System.currentTimeMillis(),
|
||||
val deviceId: String,
|
||||
val syncStatus: SyncStatus = SyncStatus.LOCAL_ONLY
|
||||
) {
|
||||
fun toJson(): String {
|
||||
return """
|
||||
{
|
||||
"id": "$id",
|
||||
"title": "${title.escapeJson()}",
|
||||
"content": "${content.escapeJson()}",
|
||||
"createdAt": $createdAt,
|
||||
"updatedAt": $updatedAt,
|
||||
"deviceId": "$deviceId",
|
||||
"syncStatus": "${syncStatus.name}"
|
||||
}
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromJson(json: String): Note? {
|
||||
return try {
|
||||
val gson = com.google.gson.Gson()
|
||||
gson.fromJson(json, Note::class.java)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extension für JSON-Escaping
|
||||
fun String.escapeJson(): String {
|
||||
return this
|
||||
.replace("\\", "\\\\")
|
||||
.replace("\"", "\\\"")
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
.replace("\t", "\\t")
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package dev.dettmer.simplenotes.models
|
||||
|
||||
enum class SyncStatus {
|
||||
LOCAL_ONLY, // Noch nie gesynct
|
||||
SYNCED, // Erfolgreich gesynct
|
||||
PENDING, // Wartet auf Sync
|
||||
CONFLICT // Konflikt erkannt
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package dev.dettmer.simplenotes.storage
|
||||
|
||||
import android.content.Context
|
||||
import dev.dettmer.simplenotes.models.Note
|
||||
import java.io.File
|
||||
|
||||
class NotesStorage(private val context: Context) {
|
||||
|
||||
private val notesDir: File = File(context.filesDir, "notes").apply {
|
||||
if (!exists()) mkdirs()
|
||||
}
|
||||
|
||||
fun saveNote(note: Note) {
|
||||
val file = File(notesDir, "${note.id}.json")
|
||||
file.writeText(note.toJson())
|
||||
}
|
||||
|
||||
fun loadNote(id: String): Note? {
|
||||
val file = File(notesDir, "$id.json")
|
||||
return if (file.exists()) {
|
||||
Note.fromJson(file.readText())
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun loadAllNotes(): List<Note> {
|
||||
return notesDir.listFiles()
|
||||
?.filter { it.extension == "json" }
|
||||
?.mapNotNull { Note.fromJson(it.readText()) }
|
||||
?.sortedByDescending { it.updatedAt }
|
||||
?: emptyList()
|
||||
}
|
||||
|
||||
fun deleteNote(id: String): Boolean {
|
||||
val file = File(notesDir, "$id.json")
|
||||
return file.delete()
|
||||
}
|
||||
|
||||
fun getNotesDir(): File = notesDir
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package dev.dettmer.simplenotes.sync
|
||||
|
||||
data class SyncResult(
|
||||
val isSuccess: Boolean,
|
||||
val syncedCount: Int = 0,
|
||||
val conflictCount: Int = 0,
|
||||
val errorMessage: String? = null
|
||||
) {
|
||||
val hasConflicts: Boolean
|
||||
get() = conflictCount > 0
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package dev.dettmer.simplenotes.sync
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import dev.dettmer.simplenotes.utils.NotificationHelper
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class SyncWorker(
|
||||
context: Context,
|
||||
params: WorkerParameters
|
||||
) : CoroutineWorker(context, params) {
|
||||
|
||||
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
|
||||
// Progress Notification zeigen
|
||||
val notificationId = NotificationHelper.showSyncProgressNotification(applicationContext)
|
||||
|
||||
return@withContext try {
|
||||
val syncService = WebDavSyncService(applicationContext)
|
||||
val syncResult = syncService.syncNotes()
|
||||
|
||||
// Progress Notification entfernen
|
||||
NotificationHelper.dismissNotification(applicationContext, notificationId)
|
||||
|
||||
when {
|
||||
syncResult.hasConflicts -> {
|
||||
// Konflikt-Notification
|
||||
NotificationHelper.showConflictNotification(
|
||||
applicationContext,
|
||||
syncResult.conflictCount
|
||||
)
|
||||
Result.success()
|
||||
}
|
||||
syncResult.isSuccess -> {
|
||||
// Erfolgs-Notification
|
||||
NotificationHelper.showSyncSuccessNotification(
|
||||
applicationContext,
|
||||
syncResult.syncedCount
|
||||
)
|
||||
Result.success()
|
||||
}
|
||||
else -> {
|
||||
// Fehler-Notification
|
||||
NotificationHelper.showSyncFailureNotification(
|
||||
applicationContext,
|
||||
syncResult.errorMessage ?: "Unbekannter Fehler"
|
||||
)
|
||||
Result.retry()
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
// Fehler-Notification
|
||||
NotificationHelper.dismissNotification(applicationContext, notificationId)
|
||||
NotificationHelper.showSyncFailureNotification(
|
||||
applicationContext,
|
||||
e.message ?: "Sync fehlgeschlagen"
|
||||
)
|
||||
|
||||
// Retry mit Backoff
|
||||
Result.retry()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package dev.dettmer.simplenotes.sync
|
||||
|
||||
import android.content.Context
|
||||
import com.thegrizzlylabs.sardineandroid.Sardine
|
||||
import com.thegrizzlylabs.sardineandroid.impl.OkHttpSardine
|
||||
import dev.dettmer.simplenotes.models.Note
|
||||
import dev.dettmer.simplenotes.models.SyncStatus
|
||||
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||
import dev.dettmer.simplenotes.utils.Constants
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.ByteArrayInputStream
|
||||
|
||||
class WebDavSyncService(private val context: Context) {
|
||||
|
||||
private val storage = NotesStorage(context)
|
||||
private val prefs = context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
private fun getSardine(): Sardine? {
|
||||
val username = prefs.getString(Constants.KEY_USERNAME, null) ?: return null
|
||||
val password = prefs.getString(Constants.KEY_PASSWORD, null) ?: return null
|
||||
|
||||
return OkHttpSardine().apply {
|
||||
setCredentials(username, password)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getServerUrl(): String? {
|
||||
return prefs.getString(Constants.KEY_SERVER_URL, null)
|
||||
}
|
||||
|
||||
suspend fun syncNotes(): SyncResult = withContext(Dispatchers.IO) {
|
||||
return@withContext try {
|
||||
val sardine = getSardine() ?: return@withContext SyncResult(
|
||||
isSuccess = false,
|
||||
errorMessage = "Server-Zugangsdaten nicht konfiguriert"
|
||||
)
|
||||
|
||||
val serverUrl = getServerUrl() ?: return@withContext SyncResult(
|
||||
isSuccess = false,
|
||||
errorMessage = "Server-URL nicht konfiguriert"
|
||||
)
|
||||
|
||||
var syncedCount = 0
|
||||
var conflictCount = 0
|
||||
|
||||
// Ensure server directory exists
|
||||
if (!sardine.exists(serverUrl)) {
|
||||
sardine.createDirectory(serverUrl)
|
||||
}
|
||||
|
||||
// Upload local notes
|
||||
val uploadedCount = uploadLocalNotes(sardine, serverUrl)
|
||||
syncedCount += uploadedCount
|
||||
|
||||
// Download remote notes
|
||||
val downloadResult = downloadRemoteNotes(sardine, serverUrl)
|
||||
syncedCount += downloadResult.downloadedCount
|
||||
conflictCount += downloadResult.conflictCount
|
||||
|
||||
// Update last sync timestamp
|
||||
saveLastSyncTimestamp()
|
||||
|
||||
SyncResult(
|
||||
isSuccess = true,
|
||||
syncedCount = syncedCount,
|
||||
conflictCount = conflictCount
|
||||
)
|
||||
|
||||
} catch (e: Exception) {
|
||||
SyncResult(
|
||||
isSuccess = false,
|
||||
errorMessage = when (e) {
|
||||
is java.net.UnknownHostException -> "Server nicht erreichbar"
|
||||
is java.net.SocketTimeoutException -> "Verbindungs-Timeout"
|
||||
is javax.net.ssl.SSLException -> "SSL-Fehler"
|
||||
is com.thegrizzlylabs.sardineandroid.impl.SardineException -> {
|
||||
when (e.statusCode) {
|
||||
401 -> "Authentifizierung fehlgeschlagen"
|
||||
403 -> "Zugriff verweigert"
|
||||
404 -> "Server-Pfad nicht gefunden"
|
||||
500 -> "Server-Fehler"
|
||||
else -> "HTTP-Fehler: ${e.statusCode}"
|
||||
}
|
||||
}
|
||||
else -> e.message ?: "Unbekannter Fehler"
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun uploadLocalNotes(sardine: Sardine, serverUrl: String): Int {
|
||||
var uploadedCount = 0
|
||||
val localNotes = storage.loadAllNotes()
|
||||
|
||||
for (note in localNotes) {
|
||||
try {
|
||||
if (note.syncStatus == SyncStatus.LOCAL_ONLY || note.syncStatus == SyncStatus.PENDING) {
|
||||
val noteUrl = "$serverUrl/${note.id}.json"
|
||||
val jsonBytes = note.toJson().toByteArray()
|
||||
|
||||
sardine.put(noteUrl, ByteArrayInputStream(jsonBytes), "application/json")
|
||||
|
||||
// Update sync status
|
||||
val updatedNote = note.copy(syncStatus = SyncStatus.SYNCED)
|
||||
storage.saveNote(updatedNote)
|
||||
uploadedCount++
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Mark as pending for retry
|
||||
val updatedNote = note.copy(syncStatus = SyncStatus.PENDING)
|
||||
storage.saveNote(updatedNote)
|
||||
}
|
||||
}
|
||||
|
||||
return uploadedCount
|
||||
}
|
||||
|
||||
private data class DownloadResult(
|
||||
val downloadedCount: Int,
|
||||
val conflictCount: Int
|
||||
)
|
||||
|
||||
private fun downloadRemoteNotes(sardine: Sardine, serverUrl: String): DownloadResult {
|
||||
var downloadedCount = 0
|
||||
var conflictCount = 0
|
||||
|
||||
try {
|
||||
val resources = sardine.list(serverUrl)
|
||||
|
||||
for (resource in resources) {
|
||||
if (resource.isDirectory || !resource.name.endsWith(".json")) {
|
||||
continue
|
||||
}
|
||||
|
||||
val noteUrl = resource.href.toString()
|
||||
val jsonContent = sardine.get(noteUrl).bufferedReader().use { it.readText() }
|
||||
val remoteNote = Note.fromJson(jsonContent) ?: continue
|
||||
|
||||
val localNote = storage.loadNote(remoteNote.id)
|
||||
|
||||
when {
|
||||
localNote == null -> {
|
||||
// New note from server
|
||||
storage.saveNote(remoteNote.copy(syncStatus = SyncStatus.SYNCED))
|
||||
downloadedCount++
|
||||
}
|
||||
localNote.updatedAt < remoteNote.updatedAt -> {
|
||||
// Remote is newer
|
||||
if (localNote.syncStatus == SyncStatus.PENDING) {
|
||||
// Conflict detected
|
||||
storage.saveNote(localNote.copy(syncStatus = SyncStatus.CONFLICT))
|
||||
conflictCount++
|
||||
} else {
|
||||
// Safe to overwrite
|
||||
storage.saveNote(remoteNote.copy(syncStatus = SyncStatus.SYNCED))
|
||||
downloadedCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Log error but don't fail entire sync
|
||||
}
|
||||
|
||||
return DownloadResult(downloadedCount, conflictCount)
|
||||
}
|
||||
|
||||
private fun saveLastSyncTimestamp() {
|
||||
prefs.edit()
|
||||
.putLong(Constants.KEY_LAST_SYNC, System.currentTimeMillis())
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun getLastSyncTimestamp(): Long {
|
||||
return prefs.getLong(Constants.KEY_LAST_SYNC, 0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package dev.dettmer.simplenotes.sync
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.wifi.WifiManager
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import dev.dettmer.simplenotes.utils.Constants
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class WifiSyncReceiver : BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
// Check if auto-sync is enabled
|
||||
val prefs = context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val autoSyncEnabled = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false)
|
||||
|
||||
if (!autoSyncEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if connected to home WiFi
|
||||
if (isConnectedToHomeWifi(context)) {
|
||||
scheduleSyncWork(context)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isConnectedToHomeWifi(context: Context): Boolean {
|
||||
val prefs = context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val homeSSID = prefs.getString(Constants.KEY_HOME_SSID, null) ?: return false
|
||||
|
||||
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE)
|
||||
as ConnectivityManager
|
||||
|
||||
val network = connectivityManager.activeNetwork ?: return false
|
||||
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
|
||||
|
||||
if (!capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Get current SSID
|
||||
val wifiManager = context.applicationContext.getSystemService(Context.WIFI_SERVICE)
|
||||
as WifiManager
|
||||
val wifiInfo = wifiManager.connectionInfo
|
||||
val currentSSID = wifiInfo.ssid.replace("\"", "")
|
||||
|
||||
return currentSSID == homeSSID
|
||||
}
|
||||
|
||||
private fun scheduleSyncWork(context: Context) {
|
||||
val syncRequest = OneTimeWorkRequestBuilder<SyncWorker>()
|
||||
.setInitialDelay(Constants.SYNC_DELAY_SECONDS, TimeUnit.SECONDS)
|
||||
.addTag(Constants.SYNC_WORK_TAG)
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(context).enqueue(syncRequest)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package dev.dettmer.simplenotes.utils
|
||||
|
||||
object Constants {
|
||||
// SharedPreferences
|
||||
const val PREFS_NAME = "simple_notes_prefs"
|
||||
const val KEY_SERVER_URL = "server_url"
|
||||
const val KEY_USERNAME = "username"
|
||||
const val KEY_PASSWORD = "password"
|
||||
const val KEY_HOME_SSID = "home_ssid"
|
||||
const val KEY_AUTO_SYNC = "auto_sync_enabled"
|
||||
const val KEY_LAST_SYNC = "last_sync_timestamp"
|
||||
|
||||
// WorkManager
|
||||
const val SYNC_WORK_TAG = "notes_sync"
|
||||
const val SYNC_DELAY_SECONDS = 5L
|
||||
|
||||
// Notifications
|
||||
const val NOTIFICATION_CHANNEL_ID = "notes_sync_channel"
|
||||
const val NOTIFICATION_ID = 1001
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package dev.dettmer.simplenotes.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.Settings
|
||||
import java.util.UUID
|
||||
|
||||
object DeviceIdGenerator {
|
||||
|
||||
private const val PREF_NAME = "simple_notes_prefs"
|
||||
private const val KEY_DEVICE_ID = "device_id"
|
||||
|
||||
fun getDeviceId(context: Context): String {
|
||||
val prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
// Check if already generated
|
||||
var deviceId = prefs.getString(KEY_DEVICE_ID, null)
|
||||
|
||||
if (deviceId == null) {
|
||||
// Try Android ID
|
||||
deviceId = Settings.Secure.getString(
|
||||
context.contentResolver,
|
||||
Settings.Secure.ANDROID_ID
|
||||
)
|
||||
|
||||
// Fallback to UUID if Android ID not available
|
||||
if (deviceId.isNullOrEmpty()) {
|
||||
deviceId = UUID.randomUUID().toString()
|
||||
}
|
||||
|
||||
// Prefix for identification
|
||||
deviceId = "android-$deviceId"
|
||||
|
||||
// Save for future use
|
||||
prefs.edit().putString(KEY_DEVICE_ID, deviceId).apply()
|
||||
}
|
||||
|
||||
return deviceId
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package dev.dettmer.simplenotes.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
// Toast Extensions
|
||||
fun Context.showToast(message: String, duration: Int = Toast.LENGTH_SHORT) {
|
||||
Toast.makeText(this, message, duration).show()
|
||||
}
|
||||
|
||||
// Timestamp to readable format
|
||||
fun Long.toReadableTime(): String {
|
||||
val now = System.currentTimeMillis()
|
||||
val diff = now - this
|
||||
|
||||
return when {
|
||||
diff < TimeUnit.MINUTES.toMillis(1) -> "Gerade eben"
|
||||
diff < TimeUnit.HOURS.toMillis(1) -> {
|
||||
val minutes = TimeUnit.MILLISECONDS.toMinutes(diff)
|
||||
"Vor $minutes Min"
|
||||
}
|
||||
diff < TimeUnit.DAYS.toMillis(1) -> {
|
||||
val hours = TimeUnit.MILLISECONDS.toHours(diff)
|
||||
"Vor $hours Std"
|
||||
}
|
||||
diff < TimeUnit.DAYS.toMillis(7) -> {
|
||||
val days = TimeUnit.MILLISECONDS.toDays(diff)
|
||||
"Vor $days Tagen"
|
||||
}
|
||||
else -> {
|
||||
val sdf = SimpleDateFormat("dd.MM.yyyy", Locale.GERMAN)
|
||||
sdf.format(Date(this))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Truncate long strings
|
||||
fun String.truncate(maxLength: Int): String {
|
||||
return if (length > maxLength) {
|
||||
substring(0, maxLength - 3) + "..."
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package dev.dettmer.simplenotes.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 androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import dev.dettmer.simplenotes.MainActivity
|
||||
|
||||
object NotificationHelper {
|
||||
|
||||
private const val CHANNEL_ID = "notes_sync_channel"
|
||||
private const val CHANNEL_NAME = "Notizen Synchronisierung"
|
||||
private const val CHANNEL_DESCRIPTION = "Benachrichtigungen über Sync-Status"
|
||||
private const val NOTIFICATION_ID = 1001
|
||||
|
||||
/**
|
||||
* Erstellt Notification Channel (Android 8.0+)
|
||||
* Muss beim App-Start aufgerufen werden
|
||||
*/
|
||||
fun createNotificationChannel(context: Context) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val importance = NotificationManager.IMPORTANCE_DEFAULT
|
||||
|
||||
val channel = NotificationChannel(CHANNEL_ID, CHANNEL_NAME, importance).apply {
|
||||
description = CHANNEL_DESCRIPTION
|
||||
enableVibration(true)
|
||||
enableLights(true)
|
||||
}
|
||||
|
||||
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE)
|
||||
as NotificationManager
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeigt Erfolgs-Notification nach Sync
|
||||
*/
|
||||
fun showSyncSuccessNotification(context: Context, syncedCount: Int) {
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
}
|
||||
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
context, 0, intent,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.ic_menu_upload)
|
||||
.setContentTitle("Sync erfolgreich")
|
||||
.setContentText("$syncedCount Notiz(en) synchronisiert")
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
|
||||
with(NotificationManagerCompat.from(context)) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (androidx.core.app.ActivityCompat.checkSelfPermission(
|
||||
context,
|
||||
android.Manifest.permission.POST_NOTIFICATIONS
|
||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
notify(NOTIFICATION_ID, notification)
|
||||
}
|
||||
} else {
|
||||
notify(NOTIFICATION_ID, notification)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeigt Fehler-Notification bei fehlgeschlagenem Sync
|
||||
*/
|
||||
fun showSyncFailureNotification(context: Context, errorMessage: String) {
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_alert)
|
||||
.setContentTitle("Sync fehlgeschlagen")
|
||||
.setContentText(errorMessage)
|
||||
.setStyle(NotificationCompat.BigTextStyle()
|
||||
.bigText(errorMessage))
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
|
||||
with(NotificationManagerCompat.from(context)) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (androidx.core.app.ActivityCompat.checkSelfPermission(
|
||||
context,
|
||||
android.Manifest.permission.POST_NOTIFICATIONS
|
||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
notify(NOTIFICATION_ID, notification)
|
||||
}
|
||||
} else {
|
||||
notify(NOTIFICATION_ID, notification)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeigt Progress-Notification während Sync läuft
|
||||
*/
|
||||
fun showSyncProgressNotification(context: Context): Int {
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.ic_popup_sync)
|
||||
.setContentTitle("Synchronisiere...")
|
||||
.setContentText("Notizen werden synchronisiert")
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setOngoing(true)
|
||||
.setProgress(0, 0, true)
|
||||
.build()
|
||||
|
||||
with(NotificationManagerCompat.from(context)) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (androidx.core.app.ActivityCompat.checkSelfPermission(
|
||||
context,
|
||||
android.Manifest.permission.POST_NOTIFICATIONS
|
||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
notify(NOTIFICATION_ID, notification)
|
||||
}
|
||||
} else {
|
||||
notify(NOTIFICATION_ID, notification)
|
||||
}
|
||||
}
|
||||
|
||||
return NOTIFICATION_ID
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeigt Notification bei erkanntem Konflikt
|
||||
*/
|
||||
fun showConflictNotification(context: Context, conflictCount: Int) {
|
||||
val intent = Intent(context, MainActivity::class.java)
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
context, 0, intent,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||
.setContentTitle("Sync-Konflikt erkannt")
|
||||
.setContentText("$conflictCount Notiz(en) haben Konflikte")
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
|
||||
with(NotificationManagerCompat.from(context)) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (androidx.core.app.ActivityCompat.checkSelfPermission(
|
||||
context,
|
||||
android.Manifest.permission.POST_NOTIFICATIONS
|
||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
notify(NOTIFICATION_ID + 1, notification)
|
||||
}
|
||||
} else {
|
||||
notify(NOTIFICATION_ID + 1, notification)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt aktive Notification
|
||||
*/
|
||||
fun dismissNotification(context: Context, notificationId: Int = NOTIFICATION_ID) {
|
||||
with(NotificationManagerCompat.from(context)) {
|
||||
cancel(notificationId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob Notification-Permission vorhanden (Android 13+)
|
||||
*/
|
||||
fun hasNotificationPermission(context: Context): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
androidx.core.app.ActivityCompat.checkSelfPermission(
|
||||
context,
|
||||
android.Manifest.permission.POST_NOTIFICATIONS
|
||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
52
android/app/src/main/res/layout/activity_editor.xml
Normal file
52
android/app/src/main/res/layout/activity_editor.xml
Normal file
@@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
|
||||
<com.google.android.material.appbar.MaterialToolbar
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
app:navigationIcon="@android:drawable/ic_menu_close_clear_cancel"
|
||||
app:title="@string/edit_note" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="16dp"
|
||||
android:hint="@string/title"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/editTextTitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text"
|
||||
android:maxLines="2" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:hint="@string/content"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/editTextContent"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="top|start"
|
||||
android:inputType="textMultiLine"
|
||||
android:scrollbars="vertical" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
</LinearLayout>
|
||||
@@ -1,19 +1,48 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/main"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context=".MainActivity">
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<com.google.android.material.appbar.AppBarLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<com.google.android.material.appbar.MaterialToolbar
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
app:title="@string/app_name" />
|
||||
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recyclerViewNotes"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipToPadding="false"
|
||||
android:padding="8dp"
|
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textViewEmpty"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Hello World!"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
android:layout_gravity="center"
|
||||
android:text="@string/no_notes_yet"
|
||||
android:textSize="18sp"
|
||||
android:textColor="?android:attr/textColorSecondary"
|
||||
android:gravity="center"
|
||||
android:visibility="gone" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
android:id="@+id/fabAddNote"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom|end"
|
||||
android:layout_margin="16dp"
|
||||
android:contentDescription="@string/add_note"
|
||||
app:srcCompat="@android:drawable/ic_input_add" />
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
|
||||
150
android/app/src/main/res/layout/activity_settings.xml
Normal file
150
android/app/src/main/res/layout/activity_settings.xml
Normal file
@@ -0,0 +1,150 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<!-- Server Settings -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/server_settings"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/server_url"
|
||||
android:layout_marginBottom="8dp"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/editTextServerUrl"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textUri" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/username"
|
||||
android:layout_marginBottom="8dp"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/editTextUsername"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/password"
|
||||
android:layout_marginBottom="16dp"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
||||
app:endIconMode="password_toggle">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/editTextPassword"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textPassword" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<!-- WiFi Settings -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/wifi_settings"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginBottom="8dp">
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:hint="@string/home_ssid"
|
||||
android:layout_marginEnd="8dp"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/editTextHomeSSID"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<Button
|
||||
android:id="@+id/buttonDetectSSID"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:text="Detect"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginBottom="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/auto_sync"
|
||||
android:textSize="16sp"
|
||||
android:layout_gravity="center_vertical" />
|
||||
|
||||
<androidx.appcompat.widget.SwitchCompat
|
||||
android:id="@+id/switchAutoSync"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Actions -->
|
||||
<Button
|
||||
android:id="@+id/buttonTestConnection"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/test_connection"
|
||||
android:layout_marginTop="8dp"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/buttonSyncNow"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/sync_now"
|
||||
android:layout_marginTop="8dp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</ScrollView>
|
||||
65
android/app/src/main/res/layout/item_note.xml
Normal file
65
android/app/src/main/res/layout/item_note.xml
Normal file
@@ -0,0 +1,65 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="4dp"
|
||||
app:cardElevation="2dp"
|
||||
app:cardCornerRadius="8dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textViewTitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Note Title"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold"
|
||||
android:maxLines="2"
|
||||
android:ellipsize="end" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textViewContent"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="Note content preview..."
|
||||
android:textSize="14sp"
|
||||
android:textColor="?android:attr/textColorSecondary"
|
||||
android:maxLines="3"
|
||||
android:ellipsize="end" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textViewTimestamp"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Vor 2 Std"
|
||||
android:textSize="12sp"
|
||||
android:textColor="?android:attr/textColorTertiary" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/imageViewSyncStatus"
|
||||
android:layout_width="16dp"
|
||||
android:layout_height="16dp"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:src="@android:drawable/ic_popup_sync"
|
||||
android:contentDescription="@string/sync_status" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
17
android/app/src/main/res/menu/menu_editor.xml
Normal file
17
android/app/src/main/res/menu/menu_editor.xml
Normal file
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<item
|
||||
android:id="@+id/action_save"
|
||||
android:title="@string/save"
|
||||
android:icon="@android:drawable/ic_menu_save"
|
||||
app:showAsAction="always" />
|
||||
|
||||
<item
|
||||
android:id="@+id/action_delete"
|
||||
android:title="@string/delete"
|
||||
android:icon="@android:drawable/ic_menu_delete"
|
||||
app:showAsAction="never" />
|
||||
|
||||
</menu>
|
||||
17
android/app/src/main/res/menu/menu_main.xml
Normal file
17
android/app/src/main/res/menu/menu_main.xml
Normal file
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<item
|
||||
android:id="@+id/action_sync"
|
||||
android:title="@string/sync"
|
||||
android:icon="@android:drawable/ic_popup_sync"
|
||||
app:showAsAction="ifRoom" />
|
||||
|
||||
<item
|
||||
android:id="@+id/action_settings"
|
||||
android:title="@string/settings"
|
||||
android:icon="@android:drawable/ic_menu_preferences"
|
||||
app:showAsAction="ifRoom" />
|
||||
|
||||
</menu>
|
||||
@@ -1,3 +1,23 @@
|
||||
<resources>
|
||||
<string name="app_name">Simple Notes</string>
|
||||
<string name="no_notes_yet">Noch keine Notizen.\nTippe + um eine zu erstellen.</string>
|
||||
<string name="add_note">Notiz hinzufügen</string>
|
||||
<string name="sync">Synchronisieren</string>
|
||||
<string name="settings">Einstellungen</string>
|
||||
<string name="edit_note">Notiz bearbeiten</string>
|
||||
<string name="new_note">Neue Notiz</string>
|
||||
<string name="title">Titel</string>
|
||||
<string name="content">Inhalt</string>
|
||||
<string name="save">Speichern</string>
|
||||
<string name="delete">Löschen</string>
|
||||
<string name="server_settings">Server-Einstellungen</string>
|
||||
<string name="server_url">Server URL</string>
|
||||
<string name="username">Benutzername</string>
|
||||
<string name="password">Passwort</string>
|
||||
<string name="wifi_settings">WLAN-Einstellungen</string>
|
||||
<string name="home_ssid">Heim-WLAN SSID</string>
|
||||
<string name="auto_sync">Auto-Sync aktiviert</string>
|
||||
<string name="test_connection">Verbindung testen</string>
|
||||
<string name="sync_now">Jetzt synchronisieren</string>
|
||||
<string name="sync_status">Sync-Status</string>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user