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:
inventory69
2025-12-20 00:59:16 +01:00
parent 20af8b6e36
commit c29542567f
22 changed files with 1581 additions and 20 deletions

View File

@@ -1,20 +1,160 @@
package dev.dettmer.simplenotes 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 android.os.Bundle
import androidx.activity.enableEdgeToEdge import android.view.Menu
import android.view.MenuItem
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat import androidx.recyclerview.widget.LinearLayoutManager
import androidx.core.view.WindowInsetsCompat 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() { 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContentView(R.layout.activity_main) setContentView(R.layout.activity_main)
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) // Notification Channel erstellen
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) NotificationHelper.createNotificationChannel(this)
insets
// 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.")
}
}
} }
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -1,19 +1,48 @@
<?xml version="1.0" encoding="utf-8"?> <?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: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_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent">
tools:context=".MainActivity">
<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 <TextView
android:id="@+id/textViewEmpty"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="Hello World!" android:layout_gravity="center"
app:layout_constraintBottom_toBottomOf="parent" android:text="@string/no_notes_yet"
app:layout_constraintEnd_toEndOf="parent" android:textSize="18sp"
app:layout_constraintStart_toStartOf="parent" android:textColor="?android:attr/textColorSecondary"
app:layout_constraintTop_toTopOf="parent" /> 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>

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

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

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

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

View File

@@ -1,3 +1,23 @@
<resources> <resources>
<string name="app_name">Simple Notes</string> <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> </resources>