diff --git a/.github/workflows/crowdin.yml b/.github/workflows/crowdin.yml new file mode 100644 index 00000000..755b84bd --- /dev/null +++ b/.github/workflows/crowdin.yml @@ -0,0 +1,38 @@ +name: Sync Crowdin + +on: + push: + branches: + - master + paths: + - 'app/src/main/res/values/strings.xml' + - 'app/src/main/res/values-*/strings.xml' + - 'crowdin.yml' + workflow_dispatch: + +concurrency: + group: crowdin-sync + cancel-in-progress: false + +jobs: + upload: + name: Upload sources and translations + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - name: Upload to Crowdin + uses: crowdin/github-action@8f01d54f70f1713ee3f09d82c2bbb2daeac28689 # v2.17.1 + with: + upload_sources: true + upload_translations: true + import_eq_suggestions: true + auto_approve_imported: false + download_translations: false + env: + CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} diff --git a/PRIVACY_POLICY.md b/PRIVACY_POLICY.md index 5d724ebf..4c541526 100644 --- a/PRIVACY_POLICY.md +++ b/PRIVACY_POLICY.md @@ -27,6 +27,16 @@ The Service Provider may use the information you provide to send important infor For a better experience while using the Application, the Service Provider may require you to provide certain personally identifiable information, including but not limited to serranoie99@gmail.com, +526691659043\. The information the Service Provider requests will be retained and used as described in this privacy policy. +**Notification Access (optional)** + +Minus offers an optional feature, off by default, that reminds you to log a charge after another app notifies you about one. If you turn it on, you grant Android's notification access permission and choose which apps Minus may look at. + +* Minus reads the text of notifications only from the apps you select, and ignores every notification from every other app. +* All matching happens on your device. Raw notification text is never received, stored, or transmitted by the Service Provider, and it is never written to disk. +* If you choose Quick Add, Minus temporarily stores only the extracted amount on your device, so it can prefill the amount field, and clears it once that field has been filled. No other part of the notification is kept. +* The only thing Minus keeps is the amount you choose to save, as a transaction in your own local data. Quick Save also records the name of the app the charge came from; Quick Add does not. +* You can revoke notification access at any time in your device settings, or turn the feature off inside Minus, and no further notifications will be read. + **Third Party Access** Only aggregated, anonymized data is periodically transmitted to external services to aid the Service Provider in improving the Application and their service. The Service Provider may share your information with third parties in the ways that are described in this privacy statement. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4e1cc86d..9f3032e3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,6 +8,13 @@ + + + + + + + + + + + + + + + > - suspend fun addTransaction(transaction: Transaction) + /** Inserts [transaction] and returns the id the row was given. */ + suspend fun addTransaction(transaction: Transaction): Long suspend fun addQueuedTransaction(transaction: Transaction) diff --git a/app/src/main/java/com/serranoie/app/minus/data/repository/BudgetRepositoryImpl.kt b/app/src/main/java/com/serranoie/app/minus/data/repository/BudgetRepositoryImpl.kt index a551f508..dd56ef82 100644 --- a/app/src/main/java/com/serranoie/app/minus/data/repository/BudgetRepositoryImpl.kt +++ b/app/src/main/java/com/serranoie/app/minus/data/repository/BudgetRepositoryImpl.kt @@ -234,9 +234,8 @@ class BudgetRepositoryImpl @Inject constructor( .map { entities -> entities.map { it.toDomain() } } } - override suspend fun addTransaction(transaction: Transaction) { + override suspend fun addTransaction(transaction: Transaction): Long = transactionDao.insert(transaction.toEntity()) - } override suspend fun addQueuedTransaction(transaction: Transaction) { queuedTransactionDao.insert(transaction.toQueuedEntity()) diff --git a/app/src/main/java/com/serranoie/app/minus/data/repository/SettingsRepository.kt b/app/src/main/java/com/serranoie/app/minus/data/repository/SettingsRepository.kt index e7683569..85bb5e32 100644 --- a/app/src/main/java/com/serranoie/app/minus/data/repository/SettingsRepository.kt +++ b/app/src/main/java/com/serranoie/app/minus/data/repository/SettingsRepository.kt @@ -75,6 +75,16 @@ interface SettingsRepository { suspend fun setNewCategoryTagEnabled(enabled: Boolean) + suspend fun setNotificationScanEnabled(enabled: Boolean) + + suspend fun setNotificationScanPackages(packages: Set) + + /** + * Adds [packageName] to the scanned apps, or removes it if it is already there, reading and + * writing the stored set in one atomic step so concurrent toggles cannot undo each other. + */ + suspend fun toggleNotificationScanPackage(packageName: String) + suspend fun setTutorialBoxCompleted(completed: Boolean) suspend fun setAnalyticsTutorialCompleted(completed: Boolean) diff --git a/app/src/main/java/com/serranoie/app/minus/data/repository/SettingsRepositoryImpl.kt b/app/src/main/java/com/serranoie/app/minus/data/repository/SettingsRepositoryImpl.kt index 144d3873..a34097a6 100644 --- a/app/src/main/java/com/serranoie/app/minus/data/repository/SettingsRepositoryImpl.kt +++ b/app/src/main/java/com/serranoie/app/minus/data/repository/SettingsRepositoryImpl.kt @@ -51,6 +51,8 @@ const val CATEGORY_GRID_MODE_KEY_NAME = "category_grid_mode_enabled" const val EXTRA_NOTE_ENABLED_KEY_NAME = "extra_note_enabled" const val RESERVE_UPCOMING_CHARGES_KEY_NAME = "reserve_upcoming_charges_enabled" const val NEW_CATEGORY_TAG_KEY_NAME = "new_category_tag_enabled" +const val NOTIFICATION_SCAN_ENABLED_KEY_NAME = "notification_scan_enabled" +const val NOTIFICATION_SCAN_PACKAGES_KEY_NAME = "notification_scan_packages" const val EARLY_FINISH_ACTIVE_KEY_NAME = "early_finish_active" const val PERIOD_END_ALREADY_HANDLED_KEY_NAME = "period_end_already_handled" const val EARLY_FINISH_ACTUAL_DATE_KEY_NAME = "early_finish_actual_date_millis" @@ -99,6 +101,9 @@ private val CATEGORY_PICKER_DIRECT_POPUP_ENABLED = booleanPreferencesKey(CATEGORY_PICKER_DIRECT_POPUP_KEY_NAME) private val CATEGORY_GRID_MODE_ENABLED = booleanPreferencesKey(CATEGORY_GRID_MODE_KEY_NAME) +private val NOTIFICATION_SCAN_ENABLED = booleanPreferencesKey(NOTIFICATION_SCAN_ENABLED_KEY_NAME) +private val NOTIFICATION_SCAN_PACKAGES = + stringSetPreferencesKey(NOTIFICATION_SCAN_PACKAGES_KEY_NAME) private val EXTRA_NOTE_ENABLED = booleanPreferencesKey(EXTRA_NOTE_ENABLED_KEY_NAME) private val RESERVE_UPCOMING_CHARGES_ENABLED = @@ -176,6 +181,8 @@ class SettingsRepositoryImpl @Inject constructor( categoryPickerDirectPopupEnabled = preferences[CATEGORY_PICKER_DIRECT_POPUP_ENABLED] ?: false, categoryGridModeEnabled = preferences[CATEGORY_GRID_MODE_ENABLED] ?: false, extraNoteEnabled = preferences[EXTRA_NOTE_ENABLED] ?: false, + notificationScanEnabled = preferences[NOTIFICATION_SCAN_ENABLED] ?: false, + notificationScanPackages = preferences[NOTIFICATION_SCAN_PACKAGES] ?: emptySet(), reserveUpcomingChargesEnabled = preferences[RESERVE_UPCOMING_CHARGES_ENABLED] ?: false, newCategoryTagEnabled = preferences[NEW_CATEGORY_TAG_ENABLED] ?: false, tutorialBoxCompleted = preferences[TUTORIAL_BOX_COMPLETED] ?: false, @@ -402,6 +409,29 @@ class SettingsRepositoryImpl @Inject constructor( } } + override suspend fun setNotificationScanEnabled(enabled: Boolean) { + dataStore.edit { preferences -> + preferences[NOTIFICATION_SCAN_ENABLED] = enabled + } + } + + override suspend fun setNotificationScanPackages(packages: Set) { + dataStore.edit { preferences -> + preferences[NOTIFICATION_SCAN_PACKAGES] = packages + } + } + + override suspend fun toggleNotificationScanPackage(packageName: String) { + dataStore.edit { preferences -> + val current = preferences[NOTIFICATION_SCAN_PACKAGES] ?: emptySet() + preferences[NOTIFICATION_SCAN_PACKAGES] = if (packageName in current) { + current - packageName + } else { + current + packageName + } + } + } + override suspend fun setTutorialBoxCompleted(completed: Boolean) { dataStore.edit { preferences -> preferences[TUTORIAL_BOX_COMPLETED] = completed diff --git a/app/src/main/java/com/serranoie/app/minus/domain/model/UserSettingsModel.kt b/app/src/main/java/com/serranoie/app/minus/domain/model/UserSettingsModel.kt index ab8815fb..0ca69482 100644 --- a/app/src/main/java/com/serranoie/app/minus/domain/model/UserSettingsModel.kt +++ b/app/src/main/java/com/serranoie/app/minus/domain/model/UserSettingsModel.kt @@ -26,6 +26,8 @@ data class UserSettings( val extraNoteEnabled: Boolean = false, val reserveUpcomingChargesEnabled: Boolean = false, val newCategoryTagEnabled: Boolean = false, + val notificationScanEnabled: Boolean = false, + val notificationScanPackages: Set = emptySet(), val tutorialBoxCompleted: Boolean = false, val firstLaunchTutorialStage: FirstLaunchTutorialStage = FirstLaunchTutorialStage.COMPLETED, val analyticsTutorialCompleted: Boolean = false, diff --git a/app/src/main/java/com/serranoie/app/minus/domain/notification/ExpenseNotificationParser.kt b/app/src/main/java/com/serranoie/app/minus/domain/notification/ExpenseNotificationParser.kt new file mode 100644 index 00000000..5b0eeae1 --- /dev/null +++ b/app/src/main/java/com/serranoie/app/minus/domain/notification/ExpenseNotificationParser.kt @@ -0,0 +1,117 @@ +package com.serranoie.app.minus.domain.notification + +import com.serranoie.app.minus.domain.model.SupportedCurrency +import java.math.BigDecimal + +const val PENDING_QUICK_ADD_AMOUNT_KEY_NAME = "pending_quick_add_amount" + +object ExpenseNotificationParser { + + private const val EVIDENCE_WINDOW = 8 + private const val SIGN_WINDOW = 4 + + private val AMOUNT = Regex( + """[+\-]?\d{1,3}(?:[.,\u00A0 ]\d{3})+(?:[.,]\d{1,2})?|[+\-]?\d+(?:[.,]\d{1,2})?""" + ) + + /** + * Symbols that more than one supported currency uses, such as "$" for USD, MXN, ARS, COP and + * CLP. Seeing one of these says nothing about which currency the amount is in, so they are not + * accepted as evidence on their own. + */ + private val SHARED_SYMBOLS: Set by lazy { + SupportedCurrency.ALL + .groupingBy { it.symbol.trim().lowercase() } + .eachCount() + .filterValues { it > 1 } + .keys + } + + fun parse( + text: String, + currencyCode: String, + denyWords: List, + ): BigDecimal? { + if (text.isBlank()) return null + val haystack = text.lowercase() + + if (denyWords.any { it.isNotBlank() && haystack.contains(it.trim().lowercase()) }) return null + + val evidence = evidenceRanges(haystack, currencyCode) + if (evidence.isEmpty()) return null + + for (match in AMOUNT.findAll(haystack)) { + if (haystack.getOrNull(match.range.last + 1) == '%') continue + val leading = haystack.substring( + (match.range.first - SIGN_WINDOW).coerceAtLeast(0), + match.range.first, + ) + if (match.value.startsWith("+") || leading.contains('+')) continue + + val from = (match.range.first - EVIDENCE_WINDOW).coerceAtLeast(0) + val to = (match.range.last + EVIDENCE_WINDOW).coerceAtMost(haystack.length - 1) + if (evidence.none { it.first >= from && it.last <= to }) continue + + val amount = normalize(match.value) ?: continue + if (amount.compareTo(BigDecimal.ZERO) == 0) continue + return amount + } + return null + } + + /** + * Where in [haystack] the configured currency is spelled out. The ISO code always counts, but + * only as a whole code, so "usdx" or "busd" is not evidence of USD. The symbol counts only when + * a single supported currency uses it. + */ + private fun evidenceRanges(haystack: String, currencyCode: String): List { + val code = currencyCode.trim().lowercase() + val ranges = mutableListOf() + if (code.isNotEmpty()) ranges += occurrencesOf(haystack, code) + + val symbol = SupportedCurrency.findByCode(currencyCode)?.symbol?.trim()?.lowercase() + if (!symbol.isNullOrEmpty() && symbol != code && symbol !in SHARED_SYMBOLS) { + ranges += occurrencesOf(haystack, symbol) + } + return ranges + } + + /** + * Every occurrence of [token] in [haystack] that is not glued to a letter on either end, so an + * alphabetic token is matched as a whole word while digits around it are still allowed + * ("12.00usd" counts, "usdx" does not). + */ + private fun occurrencesOf(haystack: String, token: String): List { + if (token.isEmpty()) return emptyList() + val ranges = mutableListOf() + var index = haystack.indexOf(token) + while (index >= 0) { + val endExclusive = index + token.length + val startIsBounded = !token.first().isLetter() || + haystack.getOrNull(index - 1)?.isLetter() != true + val endIsBounded = !token.last().isLetter() || + haystack.getOrNull(endExclusive)?.isLetter() != true + if (startIsBounded && endIsBounded) ranges += index until endExclusive + index = haystack.indexOf(token, index + 1) + } + return ranges + } + + private fun normalize(token: String): BigDecimal? { + val cleaned = token.trimStart('+', '-').filterNot { it.isWhitespace() || it == '\u00A0' } + val lastSeparator = cleaned.indexOfLast { it == '.' || it == ',' } + return runCatching { + if (lastSeparator < 0) { + BigDecimal(cleaned) + } else { + val fraction = cleaned.substring(lastSeparator + 1) + if (fraction.length in 1..2) { + val whole = cleaned.take(lastSeparator).filter { it.isDigit() } + BigDecimal("${whole.ifEmpty { "0" }}.$fraction") + } else { + BigDecimal(cleaned.filter { it.isDigit() }) + } + } + }.getOrNull() + } +} diff --git a/app/src/main/java/com/serranoie/app/minus/domain/usecase/AddTransactionUseCase.kt b/app/src/main/java/com/serranoie/app/minus/domain/usecase/AddTransactionUseCase.kt index 135dab66..f4cda58c 100644 --- a/app/src/main/java/com/serranoie/app/minus/domain/usecase/AddTransactionUseCase.kt +++ b/app/src/main/java/com/serranoie/app/minus/domain/usecase/AddTransactionUseCase.kt @@ -7,7 +7,7 @@ import javax.inject.Inject class AddTransactionUseCase @Inject constructor( private val budgetRepository: BudgetRepository ) { - suspend operator fun invoke(transaction: Transaction) { + /** Returns the id the inserted row was given. */ + suspend operator fun invoke(transaction: Transaction): Long = budgetRepository.addTransaction(transaction) - } } diff --git a/app/src/main/java/com/serranoie/app/minus/navigation/AppNavGraph.kt b/app/src/main/java/com/serranoie/app/minus/navigation/AppNavGraph.kt index 3348eb0d..c207ea53 100644 --- a/app/src/main/java/com/serranoie/app/minus/navigation/AppNavGraph.kt +++ b/app/src/main/java/com/serranoie/app/minus/navigation/AppNavGraph.kt @@ -19,7 +19,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.preferredFrameRate import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.NamedNavArgument import androidx.navigation.NavBackStackEntry import androidx.navigation.NavGraphBuilder @@ -40,6 +43,8 @@ import com.serranoie.app.minus.presentation.ui.settings.appearance.AppearanceOpt import com.serranoie.app.minus.presentation.ui.settings.bugreport.BugReportScreen import com.serranoie.app.minus.presentation.ui.settings.features.CsvSyncGuideScreen import com.serranoie.app.minus.presentation.ui.settings.features.FeatureLabScreen +import com.serranoie.app.minus.presentation.ui.settings.features.NotificationScanScreen +import com.serranoie.app.minus.presentation.ui.settings.features.NotificationScanViewModel import com.serranoie.app.minus.presentation.ui.subscriptions.SubscriptionsScreen import logcat.logcat @@ -231,6 +236,30 @@ fun AppNavGraph( onNavigateToCsvSyncGuide = { navController.navigate(Screen.CsvSyncGuide.route) }, + onNavigateToNotificationScan = { + navController.navigate(Screen.NotificationScan.route) + }, + onBack = { navController.popBackStack() }, + ) + } + + screen(Screen.NotificationScan.route, popDirection, animated) { + val viewModel: NotificationScanViewModel = hiltViewModel() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val lifecycleOwner = LocalLifecycleOwner.current + + LaunchedEffect(lifecycleOwner) { + lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { + viewModel.refreshAccess() + } + } + + NotificationScanScreen( + state = uiState, + onEnabledToggle = viewModel::onEnabledToggle, + onAppToggle = viewModel::onAppToggle, + onQueryChange = viewModel::onQueryChange, + onOpenAccessSettings = viewModel::openAccessSettings, onBack = { navController.popBackStack() }, ) } diff --git a/app/src/main/java/com/serranoie/app/minus/navigation/Screen.kt b/app/src/main/java/com/serranoie/app/minus/navigation/Screen.kt index faf2561d..b7853837 100644 --- a/app/src/main/java/com/serranoie/app/minus/navigation/Screen.kt +++ b/app/src/main/java/com/serranoie/app/minus/navigation/Screen.kt @@ -27,4 +27,6 @@ sealed class Screen(val route: String) { data object FeatureLab : Screen("feature_lab") data object CsvSyncGuide : Screen("csv_sync_guide") + + data object NotificationScan : Screen("notification_scan") } diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/MainActivity.kt b/app/src/main/java/com/serranoie/app/minus/presentation/MainActivity.kt index 8db44541..a1156d76 100644 --- a/app/src/main/java/com/serranoie/app/minus/presentation/MainActivity.kt +++ b/app/src/main/java/com/serranoie/app/minus/presentation/MainActivity.kt @@ -50,10 +50,13 @@ import com.serranoie.app.minus.domain.model.ContrastMode import com.serranoie.app.minus.domain.model.RemainingBudgetStrategy import com.serranoie.app.minus.domain.model.ThemeMode import com.serranoie.app.minus.domain.model.TypographyMode +import com.serranoie.app.minus.domain.notification.PENDING_QUICK_ADD_AMOUNT_KEY_NAME import com.serranoie.app.minus.domain.time.MidnightTransitionManager import com.serranoie.app.minus.navigation.AppNavGraph import com.serranoie.app.minus.navigation.Screen +import com.serranoie.app.minus.presentation.notification.NotificationHelper import com.serranoie.app.minus.presentation.notification.NotificationScheduler +import com.serranoie.app.minus.presentation.notification.scan.ExpenseScanActionReceiver import com.serranoie.app.minus.presentation.permission.PermissionHandler import com.serranoie.app.minus.presentation.ui.theme.MinusTheme import com.serranoie.app.minus.presentation.ui.theme.ThemeManager @@ -93,6 +96,9 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var notificationScheduler: NotificationScheduler + @Inject + lateinit var notificationHelper: NotificationHelper + @Inject lateinit var settingsRepository: SettingsRepository @@ -137,6 +143,7 @@ class MainActivity : AppCompatActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) handleSyncIntent(intent) + handleQuickAddIntent(intent) } private fun handleSyncIntent(intent: Intent?) { @@ -145,6 +152,22 @@ class MainActivity : AppCompatActivity() { } } + private fun handleQuickAddIntent(intent: Intent?) { + if (intent?.action != ExpenseScanActionReceiver.ACTION_QUICK_ADD) return + val amount = intent.getStringExtra(ExpenseScanActionReceiver.EXTRA_AMOUNT) + ?.takeIf { it.isNotBlank() } ?: return + val notificationId = intent.getIntExtra(ExpenseScanActionReceiver.EXTRA_NOTIFICATION_ID, 0) + intent.removeExtra(ExpenseScanActionReceiver.EXTRA_AMOUNT) + if (notificationId != 0) { + notificationHelper.cancelSpendNotification(notificationId) + } + lifecycleScope.launch { + runCatching { + settingsRepository.setString(PENDING_QUICK_ADD_AMOUNT_KEY_NAME, amount) + }.onFailure { logcat { "Could not stage quick add amount: ${it.asLog()}" } } + } + } + @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) override fun onCreate(savedInstanceState: Bundle?) { val splashScreen = installSplashScreen() @@ -153,6 +176,7 @@ class MainActivity : AppCompatActivity() { enableEdgeToEdge() handleSyncIntent(intent) + handleQuickAddIntent(intent) val iconAnimationEndsAt = SystemClock.uptimeMillis() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { resources.getInteger(R.integer.splash_icon_animation_duration).toLong() diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/notification/NotificationHelper.kt b/app/src/main/java/com/serranoie/app/minus/presentation/notification/NotificationHelper.kt index 4963aced..3f8fbd73 100644 --- a/app/src/main/java/com/serranoie/app/minus/presentation/notification/NotificationHelper.kt +++ b/app/src/main/java/com/serranoie/app/minus/presentation/notification/NotificationHelper.kt @@ -14,6 +14,7 @@ import androidx.core.content.ContextCompat import com.serranoie.app.minus.R import com.serranoie.app.minus.domain.model.RecurrentFrequency import com.serranoie.app.minus.presentation.MainActivity +import com.serranoie.app.minus.presentation.notification.scan.ExpenseScanActionReceiver import com.serranoie.app.minus.presentation.util.font.format.symbolOnlyCurrencyFormat import dagger.hilt.android.qualifiers.ApplicationContext import logcat.logcat @@ -30,10 +31,13 @@ class NotificationHelper @Inject constructor( const val CHANNEL_PERIOD_END = "budget_period_end" const val CHANNEL_RECURRENT = "recurrent_expenses" const val CHANNEL_CREDIT = "credit_expenses" + const val CHANNEL_SPEND_DETECTED = "spend_detected" const val NOTIFICATION_ID_PERIOD_END = 1001 const val NOTIFICATION_ID_RECURRENT = 1002 const val NOTIFICATION_ID_CREDIT = 1003 + + private const val SPEND_SAVED_TIMEOUT_MS = 15_000L } init { @@ -73,30 +77,57 @@ class NotificationHelper @Inject constructor( notificationManager.createNotificationChannel(periodEndChannel) notificationManager.createNotificationChannel(recurrentChannel) + val spendDetectedChannel = NotificationChannel( + CHANNEL_SPEND_DETECTED, + context.getString(R.string.notification_channel_spend_detected_name), + NotificationManager.IMPORTANCE_DEFAULT + ).apply { + description = context.getString(R.string.notification_channel_spend_detected_description) + enableVibration(true) + } + notificationManager.createNotificationChannel(creditChannel) + notificationManager.createNotificationChannel(spendDetectedChannel) logcat { "Notification channels created" } } - private fun checkNotificationPermission(): Boolean { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - val granted = ContextCompat.checkSelfPermission( + /** + * Whether Android would actually show a notification posted by this app: the runtime + * permission on Android 13+, notifications not turned off for the app as a whole, and — when + * [channelId] is given — that channel not turned off either. Callers that detect something + * worth notifying about should check this first, so nothing is consumed on the way to a + * notification that Android would silently drop. + */ + fun canPostNotifications(channelId: String? = null): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + ContextCompat.checkSelfPermission( context, - Manifest.permission.POST_NOTIFICATIONS - ) == PackageManager.PERMISSION_GRANTED - logcat { "Notification permission (Android 13+): $granted" } - granted - } else { - logcat { "Notification permission: granted (pre-Android 13)" } - true + Manifest.permission.POST_NOTIFICATIONS, + ) != PackageManager.PERMISSION_GRANTED + ) { + logcat { "Cannot post notifications: POST_NOTIFICATIONS is not granted" } + return false + } + + val manager = NotificationManagerCompat.from(context) + if (!manager.areNotificationsEnabled()) { + logcat { "Cannot post notifications: turned off for the whole app" } + return false } + + if (channelId == null) return true + + // A channel this app has not created yet cannot have been turned off. + val channel = manager.getNotificationChannelCompat(channelId) ?: return true + if (channel.importance == NotificationManagerCompat.IMPORTANCE_NONE) { + logcat { "Cannot post notifications: channel $channelId is turned off" } + return false + } + return true } fun showPeriodEndNotification(remainingBudget: String, currency: String) { - val hasPermission = checkNotificationPermission() - if (!hasPermission) { - logcat { "Cannot show notification - permission not granted" } - return - } + if (!canPostNotifications(CHANNEL_PERIOD_END)) return val intent = Intent(context, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK @@ -200,11 +231,7 @@ class NotificationHelper @Inject constructor( transactionId: Long? = null, occurrenceDate: LocalDate? = null, ) { - val hasPermission = checkNotificationPermission() - if (!hasPermission) { - logcat { "Cannot show notification - permission not granted" } - return - } + if (!canPostNotifications(CHANNEL_RECURRENT)) return val notificationId = transactionId?.let { recurrentNotificationId(it) } ?: NOTIFICATION_ID_RECURRENT val intent = Intent(context, MainActivity::class.java).apply { @@ -256,11 +283,7 @@ class NotificationHelper @Inject constructor( transactionId: Long? = null, occurrenceDate: LocalDate? = null, ) { - val hasPermission = checkNotificationPermission() - if (!hasPermission) { - logcat { "Cannot show notification - permission not granted" } - return - } + if (!canPostNotifications(CHANNEL_RECURRENT)) return val intent = Intent(context, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK @@ -321,11 +344,7 @@ class NotificationHelper @Inject constructor( dueDateText: String, currency: String ) { - val hasPermission = checkNotificationPermission() - if (!hasPermission) { - logcat { "Cannot show credit notification - permission not granted" } - return - } + if (!canPostNotifications(CHANNEL_CREDIT)) return val intent = Intent(context, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK @@ -360,6 +379,117 @@ class NotificationHelper @Inject constructor( NotificationManagerCompat.from(context).notify(NOTIFICATION_ID_CREDIT, notification) } + fun spendDetectedNotificationId(sourceLabel: String, amount: String): Int = + "spend_${sourceLabel}_$amount".hashCode() + + fun showSpendDetectedNotification( + amount: BigDecimal, + currency: String, + sourceLabel: String, + ) { + if (!canPostNotifications(CHANNEL_SPEND_DETECTED)) return + + val plainAmount = amount.toPlainString() + val notificationId = spendDetectedNotificationId(sourceLabel, plainAmount) + val formattedAmount = formatAmount(plainAmount, currency) + val message = context.getString(R.string.notification_scan_detected_message, formattedAmount) + + val openIntent = Intent(context, MainActivity::class.java).apply { + action = ExpenseScanActionReceiver.ACTION_QUICK_ADD + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + putExtra(ExpenseScanActionReceiver.EXTRA_AMOUNT, plainAmount) + putExtra(ExpenseScanActionReceiver.EXTRA_NOTIFICATION_ID, notificationId) + } + val openPendingIntent = PendingIntent.getActivity( + context, + notificationId, + openIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + val quickSaveIntent = Intent(context, ExpenseScanActionReceiver::class.java).apply { + action = ExpenseScanActionReceiver.ACTION_QUICK_SAVE + putExtra(ExpenseScanActionReceiver.EXTRA_AMOUNT, plainAmount) + putExtra(ExpenseScanActionReceiver.EXTRA_SOURCE_LABEL, sourceLabel) + putExtra(ExpenseScanActionReceiver.EXTRA_NOTIFICATION_ID, notificationId) + } + val quickSavePendingIntent = PendingIntent.getBroadcast( + context, + notificationId + 1, + quickSaveIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + val notification = NotificationCompat.Builder(context, CHANNEL_SPEND_DETECTED) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(sourceLabel) + .setContentText(message) + .setStyle(NotificationCompat.BigTextStyle().bigText(message)) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setContentIntent(openPendingIntent) + .setAutoCancel(true) + .setCategory(NotificationCompat.CATEGORY_REMINDER) + .addAction(0, context.getString(R.string.notification_scan_action_minus_it), openPendingIntent) + .addAction(0, context.getString(R.string.notification_scan_action_quick_save), quickSavePendingIntent) + .build() + + NotificationManagerCompat.from(context).notify(notificationId, notification) + logcat { "Spend detected notification shown for $sourceLabel" } + } + + fun showSpendSavedNotification( + notificationId: Int, + amount: String, + currency: String, + transactionId: Long, + queuedForNextPeriod: Boolean, + ) { + if (!canPostNotifications(CHANNEL_SPEND_DETECTED)) { + cancelSpendNotification(notificationId) + return + } + + val formattedAmount = formatAmount(amount, currency) + val message = if (queuedForNextPeriod) { + context.getString(R.string.notification_scan_queued_message, formattedAmount) + } else { + context.getString(R.string.notification_scan_saved_message, formattedAmount) + } + + val builder = NotificationCompat.Builder(context, CHANNEL_SPEND_DETECTED) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(context.getString(R.string.notification_scan_saved_title)) + .setContentText(message) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setAutoCancel(true) + .setTimeoutAfter(SPEND_SAVED_TIMEOUT_MS) + .setCategory(NotificationCompat.CATEGORY_STATUS) + + if (transactionId > 0L) { + val undoIntent = Intent(context, ExpenseScanActionReceiver::class.java).apply { + action = ExpenseScanActionReceiver.ACTION_UNDO + putExtra(ExpenseScanActionReceiver.EXTRA_TRANSACTION_ID, transactionId) + putExtra(ExpenseScanActionReceiver.EXTRA_NOTIFICATION_ID, notificationId) + } + builder.addAction( + 0, + context.getString(R.string.notification_scan_action_undo), + PendingIntent.getBroadcast( + context, + notificationId + 2, + undoIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ), + ) + } + + NotificationManagerCompat.from(context).notify(notificationId, builder.build()) + } + + fun cancelSpendNotification(notificationId: Int) { + NotificationManagerCompat.from(context).cancel(notificationId) + } + fun cancelAllNotifications() { NotificationManagerCompat.from(context).cancelAll() } diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/notification/NotificationRescheduleReceiver.kt b/app/src/main/java/com/serranoie/app/minus/presentation/notification/NotificationRescheduleReceiver.kt index d1c12ab6..bc7e1f54 100644 --- a/app/src/main/java/com/serranoie/app/minus/presentation/notification/NotificationRescheduleReceiver.kt +++ b/app/src/main/java/com/serranoie/app/minus/presentation/notification/NotificationRescheduleReceiver.kt @@ -4,6 +4,7 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import com.serranoie.app.minus.data.repository.BudgetRepository +import com.serranoie.app.minus.presentation.notification.scan.ExpenseNotificationListener import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.android.EntryPointAccessors @@ -27,6 +28,7 @@ class NotificationRescheduleReceiver : BroadcastReceiver() { CoroutineScope(Dispatchers.IO).launch { try { logcat { "Rescheduling notifications after system event: ${intent.action}" } + ExpenseNotificationListener.requestRebindIfGranted(context.applicationContext) val entryPoint = EntryPointAccessors.fromApplication( context.applicationContext, NotificationRescheduleReceiverEntryPoint::class.java diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/notification/scan/ExpenseNotificationListener.kt b/app/src/main/java/com/serranoie/app/minus/presentation/notification/scan/ExpenseNotificationListener.kt new file mode 100644 index 00000000..9a64a4dc --- /dev/null +++ b/app/src/main/java/com/serranoie/app/minus/presentation/notification/scan/ExpenseNotificationListener.kt @@ -0,0 +1,159 @@ +package com.serranoie.app.minus.presentation.notification.scan + +import android.app.Notification +import android.content.ComponentName +import android.content.Context +import android.service.notification.NotificationListenerService +import android.service.notification.StatusBarNotification +import com.serranoie.app.minus.R +import com.serranoie.app.minus.data.repository.BudgetRepository +import com.serranoie.app.minus.data.repository.SettingsRepository +import com.serranoie.app.minus.domain.notification.ExpenseNotificationParser +import com.serranoie.app.minus.presentation.notification.NotificationHelper +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import logcat.asLog +import logcat.logcat +import java.math.BigDecimal +import javax.inject.Inject + +@AndroidEntryPoint +class ExpenseNotificationListener : NotificationListenerService() { + + companion object { + private const val DEDUP_WINDOW_MS = 60_000L + private const val RATE_WINDOW_MS = 60 * 60_000L + private const val MAX_PER_RATE_WINDOW = 10 + + fun requestRebindIfGranted(context: Context) { + val component = ComponentName(context, ExpenseNotificationListener::class.java) + runCatching { requestRebind(component) } + .onFailure { logcat { "Could not request listener rebind: ${it.asLog()}" } } + } + } + + @Inject + lateinit var settingsRepository: SettingsRepository + + @Inject + lateinit var budgetRepository: BudgetRepository + + @Inject + lateinit var notificationHelper: NotificationHelper + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val seen = ArrayDeque>() + + @Volatile + private var connectedAt = Long.MAX_VALUE + + @Volatile + private var enabled = false + + @Volatile + private var watchedPackages: Set = emptySet() + + private var settingsJob: Job? = null + + override fun onListenerConnected() { + super.onListenerConnected() + connectedAt = System.currentTimeMillis() + settingsJob?.cancel() + settingsJob = scope.launch { + settingsRepository.observeSettings().collect { settings -> + enabled = settings.notificationScanEnabled + watchedPackages = settings.notificationScanPackages + if (!settings.notificationScanEnabled) requestUnbind() + } + } + logcat { "Expense notification listener connected" } + } + + override fun onListenerDisconnected() { + super.onListenerDisconnected() + connectedAt = Long.MAX_VALUE + settingsJob?.cancel() + settingsJob = null + } + + override fun onDestroy() { + scope.cancel() + super.onDestroy() + } + + override fun onNotificationPosted(sbn: StatusBarNotification) { + if (sbn.packageName == packageName) return + if (sbn.postTime < connectedAt) return + if (!enabled || sbn.packageName !in watchedPackages) return + + val flags = sbn.notification?.flags ?: return + if (flags and Notification.FLAG_GROUP_SUMMARY != 0) return + if (flags and Notification.FLAG_ONGOING_EVENT != 0) return + + val text = readText(sbn) + if (text.isBlank()) return + + scope.launch { + try { + val currencyCode = budgetRepository.getBudgetSettingsSync()?.currencyCode ?: "USD" + val amount = ExpenseNotificationParser.parse( + text = text, + currencyCode = currencyCode, + denyWords = getString(R.string.notification_scan_deny_words).split(","), + ) ?: return@launch + + // Checked before admit() so a dropped notification does not burn this spend's + // dedup/rate budget: nothing is counted that cannot be shown. + if (!notificationHelper.canPostNotifications(NotificationHelper.CHANNEL_SPEND_DETECTED)) { + logcat { "Skipping scanned spend: cannot post notifications" } + return@launch + } + + if (!admit(sbn.packageName, amount)) return@launch + + notificationHelper.showSpendDetectedNotification( + amount = amount, + currency = currencyCode, + sourceLabel = appLabel(sbn.packageName), + ) + } catch (e: Exception) { + logcat { "Error handling scanned notification\n${e.asLog()}" } + } + } + } + + private fun readText(sbn: StatusBarNotification): String { + val extras = sbn.notification?.extras ?: return "" + return listOf( + Notification.EXTRA_TITLE, + Notification.EXTRA_TEXT, + Notification.EXTRA_BIG_TEXT, + Notification.EXTRA_SUB_TEXT, + ).mapNotNull { key -> + runCatching { extras.getCharSequence(key)?.toString() }.getOrNull() + }.filter { it.isNotBlank() }.joinToString(separator = " ") + } + + private fun admit(sourcePackage: String, amount: BigDecimal): Boolean = synchronized(seen) { + val now = System.currentTimeMillis() + while (seen.isNotEmpty() && now - seen.first().second > RATE_WINDOW_MS) { + seen.removeFirst() + } + val key = "$sourcePackage|${amount.toPlainString()}" + if (seen.any { it.first == key && now - it.second <= DEDUP_WINDOW_MS }) return false + if (seen.size >= MAX_PER_RATE_WINDOW) return false + seen.addLast(key to now) + true + } + + private fun appLabel(sourcePackage: String): String = runCatching { + packageManager.getApplicationLabel( + packageManager.getApplicationInfo(sourcePackage, 0), + ).toString() + }.getOrDefault(sourcePackage) +} diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/notification/scan/ExpenseScanActionReceiver.kt b/app/src/main/java/com/serranoie/app/minus/presentation/notification/scan/ExpenseScanActionReceiver.kt new file mode 100644 index 00000000..7e95d5ab --- /dev/null +++ b/app/src/main/java/com/serranoie/app/minus/presentation/notification/scan/ExpenseScanActionReceiver.kt @@ -0,0 +1,143 @@ +package com.serranoie.app.minus.presentation.notification.scan + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationManagerCompat +import com.serranoie.app.minus.data.repository.BudgetRepository +import com.serranoie.app.minus.domain.usecase.GetCurrentPeriodIdUseCase +import com.serranoie.app.minus.presentation.notification.NotificationHelper +import com.serranoie.app.minus.presentation.ui.budget.ApplyTransactionResult +import com.serranoie.app.minus.presentation.ui.budget.BudgetTransactionHandler +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import logcat.asLog +import logcat.logcat +import java.util.concurrent.ConcurrentHashMap + +class ExpenseScanActionReceiver : BroadcastReceiver() { + companion object { + const val ACTION_QUICK_SAVE = "com.serranoie.app.minus.action.SCAN_QUICK_SAVE" + const val ACTION_UNDO = "com.serranoie.app.minus.action.SCAN_UNDO" + const val ACTION_QUICK_ADD = "com.serranoie.app.minus.action.SCAN_QUICK_ADD" + + const val EXTRA_AMOUNT = "scan_amount" + const val EXTRA_SOURCE_LABEL = "scan_source_label" + const val EXTRA_TRANSACTION_ID = "scan_transaction_id" + const val EXTRA_NOTIFICATION_ID = "scan_notification_id" + + /** + * Notification ids with a quick save in flight. Claiming an id is atomic, so two + * broadcasts for the same notification — a double tap, or a redelivery — can never both + * insert a transaction, while a quick save for any other notification still proceeds. + * The id is released once the save finishes, because ids repeat for a later charge of the + * same amount from the same app. + */ + private val quickSavesInFlight = ConcurrentHashMap.newKeySet() + } + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface ExpenseScanActionEntryPoint { + fun budgetRepository(): BudgetRepository + fun budgetTransactionHandler(): BudgetTransactionHandler + fun getCurrentPeriodIdUseCase(): GetCurrentPeriodIdUseCase + fun notificationHelper(): NotificationHelper + } + + override fun onReceive(context: Context, intent: Intent) { + val action = intent.action ?: return + if (action != ACTION_QUICK_SAVE && action != ACTION_UNDO) return + + val notificationId = intent.getIntExtra(EXTRA_NOTIFICATION_ID, 0) + val pendingResult = goAsync() + CoroutineScope(Dispatchers.IO).launch { + try { + withContext(NonCancellable) { + val entryPoint = EntryPointAccessors.fromApplication( + context.applicationContext, + ExpenseScanActionEntryPoint::class.java, + ) + when (action) { + ACTION_QUICK_SAVE -> quickSave(entryPoint, intent, notificationId) + ACTION_UNDO -> undo(entryPoint, context, intent, notificationId) + } + } + } catch (e: Exception) { + logcat { "Error handling scan notification action $action\n${e.asLog()}" } + } finally { + pendingResult.finish() + } + } + } + + private suspend fun quickSave( + entryPoint: ExpenseScanActionEntryPoint, + intent: Intent, + notificationId: Int, + ) { + if (!quickSavesInFlight.add(notificationId)) { + logcat { "Ignoring concurrent quick save for notification $notificationId" } + return + } + + try { + val amount = intent.getStringExtra(EXTRA_AMOUNT)?.takeIf { it.isNotBlank() } ?: return + val sourceLabel = intent.getStringExtra(EXTRA_SOURCE_LABEL).orEmpty() + val budgetRepository = entryPoint.budgetRepository() + val budgetSettings = budgetRepository.getBudgetSettingsSync() + + val result = entryPoint.budgetTransactionHandler().applyTransaction( + input = amount, + isCalculation = false, + isRecurrentEnabled = false, + isCreditEnabled = false, + comment = "", + note = sourceLabel, + budgetSettings = budgetSettings, + resolveActivePeriodId = { entryPoint.getCurrentPeriodIdUseCase().invoke() }, + ) + + val notificationHelper = entryPoint.notificationHelper() + when (result) { + is ApplyTransactionResult.Added, + is ApplyTransactionResult.QueuedForNextPeriod -> { + notificationHelper.showSpendSavedNotification( + notificationId = notificationId, + amount = amount, + currency = budgetSettings?.currencyCode ?: "USD", + transactionId = (result as? ApplyTransactionResult.Added)?.transactionId ?: 0L, + queuedForNextPeriod = result is ApplyTransactionResult.QueuedForNextPeriod, + ) + } + + else -> { + logcat { "Scan quick save rejected: $result" } + notificationHelper.cancelSpendNotification(notificationId) + } + } + } finally { + quickSavesInFlight.remove(notificationId) + } + } + + private suspend fun undo( + entryPoint: ExpenseScanActionEntryPoint, + context: Context, + intent: Intent, + notificationId: Int, + ) { + NotificationManagerCompat.from(context).cancel(notificationId) + val transactionId = intent.getLongExtra(EXTRA_TRANSACTION_ID, 0L) + if (transactionId <= 0L) return + val transaction = entryPoint.budgetRepository().getTransactionById(transactionId) ?: return + entryPoint.budgetTransactionHandler().deleteTransaction(transaction) + } +} diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/ui/budget/BudgetTransactionHandler.kt b/app/src/main/java/com/serranoie/app/minus/presentation/ui/budget/BudgetTransactionHandler.kt index 7acb11e2..9dfb7927 100644 --- a/app/src/main/java/com/serranoie/app/minus/presentation/ui/budget/BudgetTransactionHandler.kt +++ b/app/src/main/java/com/serranoie/app/minus/presentation/ui/budget/BudgetTransactionHandler.kt @@ -27,6 +27,7 @@ sealed interface ApplyTransactionResult { data class Added( val normalizedInput: String, + val transactionId: Long = 0L, ) : ApplyTransactionResult data object InvalidInput : ApplyTransactionResult @@ -122,8 +123,11 @@ class BudgetTransactionHandler @Inject constructor( isCredit = isCreditEnabled, isAdjustment = isAdjustment ) - addTransactionUseCase(transaction) - ApplyTransactionResult.Added(normalizedInput = normalizedInput) + val transactionId = addTransactionUseCase(transaction) + ApplyTransactionResult.Added( + normalizedInput = normalizedInput, + transactionId = transactionId, + ) } catch (e: CancellationException) { throw e } catch (e: Exception) { diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/ui/budget/BudgetViewModel.kt b/app/src/main/java/com/serranoie/app/minus/presentation/ui/budget/BudgetViewModel.kt index cab816ec..aa3caf32 100644 --- a/app/src/main/java/com/serranoie/app/minus/presentation/ui/budget/BudgetViewModel.kt +++ b/app/src/main/java/com/serranoie/app/minus/presentation/ui/budget/BudgetViewModel.kt @@ -17,6 +17,7 @@ import com.serranoie.app.minus.domain.model.RecurrentFrequency import com.serranoie.app.minus.domain.model.RemainingBudgetStrategy import com.serranoie.app.minus.domain.model.Transaction import com.serranoie.app.minus.domain.model.calculatePaymentDueDate +import com.serranoie.app.minus.domain.notification.PENDING_QUICK_ADD_AMOUNT_KEY_NAME import com.serranoie.app.minus.domain.time.MidnightTransitionManager import com.serranoie.app.minus.domain.usecase.ClearEarlyFinishStateUseCase import com.serranoie.app.minus.domain.usecase.FinishBudgetEarlyUseCase @@ -57,6 +58,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn @@ -228,6 +230,14 @@ class BudgetViewModel @Inject constructor( budgetWidgetUpdater.update(baseState) } } + viewModelScope.launch { + settingsRepository.observeString(PENDING_QUICK_ADD_AMOUNT_KEY_NAME) + .filter { !it.isNullOrBlank() } + .collect { amount -> + numpadController.setInput(amount.orEmpty()) + settingsRepository.setString(PENDING_QUICK_ADD_AMOUNT_KEY_NAME, "") + } + } } private fun buildPeriodBoundaryFlow() = observeCurrentPeriodBoundaryUseCase().map { boundary -> diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/ui/settings/bugreport/BugReportForm.kt b/app/src/main/java/com/serranoie/app/minus/presentation/ui/settings/bugreport/BugReportForm.kt index 467d343d..7ebf2a6d 100644 --- a/app/src/main/java/com/serranoie/app/minus/presentation/ui/settings/bugreport/BugReportForm.kt +++ b/app/src/main/java/com/serranoie/app/minus/presentation/ui/settings/bugreport/BugReportForm.kt @@ -109,6 +109,7 @@ import com.serranoie.app.minus.presentation.ui.settings.bugreport.mvi.BugReportI import com.serranoie.app.minus.presentation.ui.settings.bugreport.mvi.BugReportUiIntent import com.serranoie.app.minus.presentation.ui.settings.bugreport.mvi.BugReportUiState import com.serranoie.app.minus.presentation.ui.theme.MinusTheme +import com.serranoie.app.minus.presentation.ui.theme.component.HelpBanner import com.serranoie.app.minus.presentation.ui.theme.bodySmallCondensed import com.serranoie.app.minus.presentation.util.Utils.confirmFeedback import kotlinx.coroutines.delay @@ -171,10 +172,32 @@ fun BugReportForm( .verticalScroll(rememberScrollState()) .padding(horizontal = 14.dp, vertical = 16.dp) ) { + val helpAccentColor = MaterialTheme.colorScheme.primary + val helpMessagePrefix = stringResource(R.string.bug_report_help_message_prefix) + val helpLinkLabel = stringResource(R.string.bug_report_help_github_issues) + val helpMessageSuffix = stringResource(R.string.bug_report_help_message_suffix) + val githubIssuesUrl = stringResource(R.string.bug_report_github_issues_url) + HelpBanner( + text = buildAnnotatedString { + append(helpMessagePrefix) + withLink( + LinkAnnotation.Url( + url = githubIssuesUrl, + styles = TextLinkStyles( + style = SpanStyle( + color = helpAccentColor, + fontWeight = FontWeight.Bold, + ) + ) + ) + ) { + append(helpLinkLabel) + } + append(helpMessageSuffix) + }, + title = stringResource(R.string.bug_report_help_title), modifier = Modifier.fillMaxWidth(), - backgroundColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.25f), - accentColor = MaterialTheme.colorScheme.primary ) Spacer(modifier = Modifier.height(24.dp)) @@ -402,62 +425,6 @@ private fun IssueTypeSelector( } } -@Composable -private fun HelpBanner( - backgroundColor: Color, - accentColor: Color, - modifier: Modifier = Modifier -) { - Column( - modifier = modifier - .clip(RoundedCornerShape(20.dp)) - .background(backgroundColor) - .padding(horizontal = 16.dp, vertical = 13.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = Icons.AutoMirrored.Outlined.Help, - contentDescription = null, - tint = MaterialTheme.colorScheme.outline, - modifier = Modifier.size(20.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = stringResource(R.string.bug_report_help_title), - style = MaterialTheme.typography.bodySmallCondensed, - color = MaterialTheme.colorScheme.outline - ) - } - - val helpMessagePrefix = stringResource(R.string.bug_report_help_message_prefix) - val helpLinkLabel = stringResource(R.string.bug_report_help_github_issues) - val helpMessageSuffix = stringResource(R.string.bug_report_help_message_suffix) - val githubIssuesUrl = stringResource(R.string.bug_report_github_issues_url) - - Text( - text = buildAnnotatedString { - append(helpMessagePrefix) - withLink( - LinkAnnotation.Url( - url = githubIssuesUrl, - styles = TextLinkStyles( - style = SpanStyle(color = accentColor, fontWeight = FontWeight.Bold) - ) - ) - ) { - append(helpLinkLabel) - } - append(helpMessageSuffix) - }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - lineHeight = 14.sp, - modifier = Modifier.fillMaxWidth() - ) - } -} - @Composable private fun FormField( label: String, diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/ui/settings/features/FeatureLabScreen.kt b/app/src/main/java/com/serranoie/app/minus/presentation/ui/settings/features/FeatureLabScreen.kt index 8cc06ed3..cfec3e41 100644 --- a/app/src/main/java/com/serranoie/app/minus/presentation/ui/settings/features/FeatureLabScreen.kt +++ b/app/src/main/java/com/serranoie/app/minus/presentation/ui/settings/features/FeatureLabScreen.kt @@ -3,23 +3,21 @@ package com.serranoie.app.minus.presentation.ui.settings.features import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.automirrored.filled.ViewList -import androidx.compose.material.icons.automirrored.outlined.Help import androidx.compose.material.icons.rounded.CreditCard import androidx.compose.material.icons.rounded.EditNote import androidx.compose.material.icons.rounded.EventRepeat import androidx.compose.material.icons.rounded.NewLabel +import androidx.compose.material.icons.rounded.NotificationsActive import androidx.compose.material.icons.rounded.Sell import androidx.compose.material.icons.rounded.Terminal import androidx.compose.material.icons.rounded.YoutubeSearchedFor @@ -35,7 +33,6 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.testTag @@ -45,9 +42,9 @@ import androidx.compose.ui.unit.dp import com.serranoie.app.minus.R import com.serranoie.app.minus.presentation.ui.settings.SettingsUiState import com.serranoie.app.minus.presentation.ui.theme.MinusTheme -import com.serranoie.app.minus.presentation.ui.theme.bodySmallCondensed import com.serranoie.app.minus.presentation.ui.theme.component.PaddedListGroup import com.serranoie.app.minus.presentation.ui.theme.component.CustomPaddedListItem +import com.serranoie.app.minus.presentation.ui.theme.component.HelpBanner import com.serranoie.app.minus.presentation.ui.theme.component.PaddedListItemPosition import com.serranoie.app.minus.presentation.ui.theme.component.SettingsLeadingIcon import com.serranoie.app.minus.presentation.ui.theme.component.SettingsToggleItem @@ -63,6 +60,7 @@ fun FeatureLabScreen( onReserveUpcomingChargesToggle: () -> Unit, onNewCategoryTagToggle: () -> Unit, onNavigateToCsvSyncGuide: () -> Unit = {}, + onNavigateToNotificationScan: () -> Unit = {}, onBack: () -> Unit, ) { val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior( @@ -78,7 +76,12 @@ fun FeatureLabScreen( .fillMaxSize() .padding(innerPadding), ) { - item { FeatureLabIntro() } + item { + HelpBanner( + text = stringResource(R.string.settings_feature_lab_header), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) + } item { PaddedListGroup( @@ -166,7 +169,41 @@ fun FeatureLabScreen( } item { - PaddedListGroup { + PaddedListGroup( + title = stringResource(R.string.settings_feature_group_notifications) + ) { + CustomPaddedListItem( + onClick = onNavigateToNotificationScan, + position = PaddedListItemPosition.Single, + modifier = Modifier.testTag("FeatureLabNotificationScan"), + ) { + SettingsLeadingIcon(icon = Icons.Rounded.NotificationsActive) + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.settings_feature_notification_scan_title), + style = MaterialTheme.typography.bodyMediumEmphasized, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = stringResource(R.string.settings_feature_notification_scan_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + item { + PaddedListGroup( + title = stringResource(R.string.settings_feature_group_experimental) + ) { CustomPaddedListItem( onClick = onNavigateToCsvSyncGuide, position = PaddedListItemPosition.Single, @@ -200,34 +237,6 @@ fun FeatureLabScreen( } } -@Composable -private fun FeatureLabIntro() { - Column(modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = Icons.AutoMirrored.Outlined.Help, - contentDescription = null, - tint = MaterialTheme.colorScheme.outline, - modifier = Modifier.size(20.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = stringResource(R.string.settings_what_is_this_for), - style = MaterialTheme.typography.bodySmallCondensed, - color = MaterialTheme.colorScheme.outline - ) - } - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = stringResource(R.string.settings_feature_lab_header), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } -} - @Composable private fun FeatureLabTopBar( onBack: () -> Unit, diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/ui/settings/features/NotificationScanScreen.kt b/app/src/main/java/com/serranoie/app/minus/presentation/ui/settings/features/NotificationScanScreen.kt new file mode 100644 index 00000000..e1c2ab4a --- /dev/null +++ b/app/src/main/java/com/serranoie/app/minus/presentation/ui/settings/features/NotificationScanScreen.kt @@ -0,0 +1,260 @@ +@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) + +package com.serranoie.app.minus.presentation.ui.settings.features + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.rounded.NotificationsActive +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LargeTopAppBar +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import com.serranoie.app.minus.R +import com.serranoie.app.minus.presentation.ui.theme.MinusTheme +import com.serranoie.app.minus.presentation.ui.theme.component.CustomPaddedListItem +import com.serranoie.app.minus.presentation.ui.theme.component.HelpBanner +import com.serranoie.app.minus.presentation.ui.theme.component.PaddedListGroup +import com.serranoie.app.minus.presentation.ui.theme.component.PaddedListItemPosition +import com.serranoie.app.minus.presentation.ui.theme.component.SettingsToggleItem + +@Composable +fun NotificationScanScreen( + state: NotificationScanUiState, + onEnabledToggle: () -> Unit, + onAppToggle: (String) -> Unit, + onQueryChange: (String) -> Unit, + onOpenAccessSettings: () -> Unit, + onBack: () -> Unit, +) { + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior( + rememberTopAppBarState() + ) + + val visibleApps = remember(state.apps, state.query) { + if (state.query.isBlank()) { + state.apps + } else { + state.apps.filter { it.label.contains(state.query, ignoreCase = true) } + } + } + + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { NotificationScanTopBar(onBack, scrollBehavior) }, + ) { innerPadding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + item { + HelpBanner( + text = stringResource(R.string.notification_scan_header), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) + } + + if (!state.accessGranted) { + item { + PaddedListGroup( + title = stringResource(R.string.notification_scan_access_title) + ) { + CustomPaddedListItem(position = PaddedListItemPosition.Single) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.notification_scan_access_missing), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(12.dp)) + Button( + onClick = onOpenAccessSettings, + modifier = Modifier.testTag("NotificationScanAccessButton"), + ) { + Text( + text = stringResource(R.string.notification_scan_access_button) + ) + } + } + } + } + } + } + + item { + PaddedListGroup { + SettingsToggleItem( + icon = Icons.Rounded.NotificationsActive, + title = stringResource(R.string.notification_scan_enable_title), + description = stringResource(R.string.notification_scan_enable_description), + checked = state.enabled, + onToggle = onEnabledToggle, + position = PaddedListItemPosition.Single, + label = stringResource(R.string.settings_feature_experimental_label), + modifier = Modifier.testTag("NotificationScanEnable"), + ) + } + } + + item { + Column(modifier = Modifier.padding(horizontal = 16.dp)) { + Text( + text = stringResource(R.string.notification_scan_apps_title), + style = MaterialTheme.typography.labelLargeEmphasized, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(bottom = 10.dp, start = 4.dp), + ) + Text( + text = if (state.selectedPackages.isEmpty()) { + stringResource(R.string.notification_scan_apps_none_selected) + } else { + stringResource( + R.string.notification_scan_selected_count, + state.selectedPackages.size, + ) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 10.dp, start = 4.dp), + ) + OutlinedTextField( + value = state.query, + onValueChange = onQueryChange, + singleLine = true, + label = { + Text(text = stringResource(R.string.notification_scan_search_label)) + }, + leadingIcon = { + Icon(imageVector = Icons.Rounded.Search, contentDescription = null) + }, + modifier = Modifier + .fillMaxWidth() + .testTag("NotificationScanSearch"), + ) + Spacer(modifier = Modifier.height(12.dp)) + } + } + + if (visibleApps.isEmpty() && !state.loadingApps) { + item { + Text( + text = stringResource(R.string.notification_scan_apps_empty), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 20.dp), + ) + } + } + + items(visibleApps, key = { it.packageName }) { app -> + Column(modifier = Modifier.padding(PaddingValues(horizontal = 16.dp, vertical = 3.dp))) { + CustomPaddedListItem( + onClick = { onAppToggle(app.packageName) }, + position = PaddedListItemPosition.Single, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = app.label, + style = MaterialTheme.typography.bodyMediumEmphasized, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = app.packageName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(modifier = Modifier.width(12.dp)) + Checkbox( + checked = app.packageName in state.selectedPackages, + onCheckedChange = { onAppToggle(app.packageName) }, + ) + } + } + } + + item { Spacer(modifier = Modifier.height(24.dp)) } + } + } +} + +@Composable +private fun NotificationScanTopBar( + onBack: () -> Unit, + scrollBehavior: TopAppBarScrollBehavior, +) { + LargeTopAppBar( + title = { + Text( + text = stringResource(R.string.notification_scan_screen_title), + style = MaterialTheme.typography.titleLargeEmphasized, + ) + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back), + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + titleContentColor = MaterialTheme.colorScheme.onSurface, + ), + scrollBehavior = scrollBehavior, + ) +} + +@PreviewLightDark +@Composable +private fun NotificationScanScreenPreview() { + MinusTheme { + NotificationScanScreen( + state = NotificationScanUiState( + accessGranted = true, + enabled = true, + selectedPackages = setOf("com.bank.app"), + apps = listOf( + ScannableApp("com.bank.app", "My Bank"), + ScannableApp("com.wallet.app", "Wallet"), + ), + loadingApps = false, + ), + onEnabledToggle = {}, + onAppToggle = {}, + onQueryChange = {}, + onOpenAccessSettings = {}, + onBack = {}, + ) + } +} diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/ui/settings/features/NotificationScanViewModel.kt b/app/src/main/java/com/serranoie/app/minus/presentation/ui/settings/features/NotificationScanViewModel.kt new file mode 100644 index 00000000..a8756431 --- /dev/null +++ b/app/src/main/java/com/serranoie/app/minus/presentation/ui/settings/features/NotificationScanViewModel.kt @@ -0,0 +1,127 @@ +package com.serranoie.app.minus.presentation.ui.settings.features + +import android.content.Context +import android.content.Intent +import android.provider.Settings +import androidx.core.app.NotificationManagerCompat +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.serranoie.app.minus.data.repository.SettingsRepository +import com.serranoie.app.minus.presentation.notification.scan.ExpenseNotificationListener +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +data class ScannableApp( + val packageName: String, + val label: String, +) + +data class NotificationScanUiState( + val accessGranted: Boolean = false, + val enabled: Boolean = false, + val selectedPackages: Set = emptySet(), + val apps: List = emptyList(), + val query: String = "", + val loadingApps: Boolean = true, +) + +@HiltViewModel +class NotificationScanViewModel @Inject constructor( + @param:ApplicationContext private val context: Context, + private val settingsRepository: SettingsRepository, +) : ViewModel() { + + private val accessGranted = MutableStateFlow(isAccessGranted()) + private val apps = MutableStateFlow?>(null) + private val query = MutableStateFlow("") + + val uiState: StateFlow = combine( + settingsRepository.observeSettings(), + accessGranted, + apps, + query, + ) { settings, granted, loadedApps, searchQuery -> + NotificationScanUiState( + accessGranted = granted, + enabled = settings.notificationScanEnabled, + selectedPackages = settings.notificationScanPackages, + apps = loadedApps.orEmpty(), + query = searchQuery, + loadingApps = loadedApps == null, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = NotificationScanUiState(), + ) + + init { + loadApps() + } + + fun refreshAccess() { + accessGranted.value = isAccessGranted() + } + + fun onQueryChange(value: String) { + query.value = value + } + + fun onEnabledToggle() { + val enabled = !uiState.value.enabled + viewModelScope.launch { + settingsRepository.setNotificationScanEnabled(enabled) + if (enabled) { + ExpenseNotificationListener.requestRebindIfGranted(context) + } + } + } + + fun onAppToggle(packageName: String) { + viewModelScope.launch { + settingsRepository.toggleNotificationScanPackage(packageName) + } + } + + fun openAccessSettings() { + runCatching { + context.startActivity( + Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + ) + } + } + + private fun isAccessGranted(): Boolean = + context.packageName in NotificationManagerCompat.getEnabledListenerPackages(context) + + private fun loadApps() { + viewModelScope.launch { + val loaded = withContext(Dispatchers.IO) { + val packageManager = context.packageManager + val launchable = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER) + packageManager.queryIntentActivities(launchable, 0) + .mapNotNull { it.activityInfo?.applicationInfo } + .filter { it.packageName != context.packageName } + .distinctBy { it.packageName } + .map { + ScannableApp( + packageName = it.packageName, + label = packageManager.getApplicationLabel(it).toString(), + ) + } + .sortedBy { it.label.lowercase() } + } + apps.value = loaded + } + } +} diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/ui/theme/component/HelpBanner.kt b/app/src/main/java/com/serranoie/app/minus/presentation/ui/theme/component/HelpBanner.kt new file mode 100644 index 00000000..2ccc8a09 --- /dev/null +++ b/app/src/main/java/com/serranoie/app/minus/presentation/ui/theme/component/HelpBanner.kt @@ -0,0 +1,99 @@ +package com.serranoie.app.minus.presentation.ui.theme.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.Help +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.serranoie.app.minus.R +import com.serranoie.app.minus.presentation.ui.theme.MinusTheme +import com.serranoie.app.minus.presentation.ui.theme.bodySmallCondensed + +@Composable +fun HelpBanner( + text: AnnotatedString, + modifier: Modifier = Modifier, + title: String = stringResource(R.string.settings_what_is_this_for), + icon: ImageVector = Icons.AutoMirrored.Outlined.Help, + backgroundColor: Color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.25f), +) { + Column( + modifier = modifier + .clip(RoundedCornerShape(20.dp)) + .background(backgroundColor) + .padding(horizontal = 16.dp, vertical = 13.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.outline, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = title, + style = MaterialTheme.typography.bodySmallCondensed, + color = MaterialTheme.colorScheme.outline + ) + } + + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + lineHeight = 14.sp, + modifier = Modifier.fillMaxWidth() + ) + } +} + +@Composable +fun HelpBanner( + text: String, + modifier: Modifier = Modifier, + title: String = stringResource(R.string.settings_what_is_this_for), + icon: ImageVector = Icons.AutoMirrored.Outlined.Help, + backgroundColor: Color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.25f), +) { + HelpBanner( + text = AnnotatedString(text), + modifier = modifier, + title = title, + icon = icon, + backgroundColor = backgroundColor, + ) +} + +@PreviewLightDark +@Composable +private fun HelpBannerPreview() { + MinusTheme { + HelpBanner( + text = "Minus reads the notifications of the apps you pick, on your device only.", + modifier = Modifier.padding(14.dp), + ) + } +} diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index 1d74b98c..0b22ffe7 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -738,4 +738,60 @@ Всичко, което се повтаря, и колко ви струва на период. Отворете, за да видите следващата дата на плащане за всяко или да спрете поредица, която вече не плащате. Върнати пари Всичко, което сте записали със знак + в началото — заплата, възстановена сума, входящ превод. Добавя се към този период, вместо да се брои като разход, така че оставащият бюджет се увеличава. + Известия + Експериментално + Експериментално + Напомняния за разходи от известия + Следи известията на избраните от вас приложения и когато някое изглежда като плащане, ви напомня да го запишете в Minus. + Напомняния за разходи + Minus чете известията само на избраните от вас приложения и само на вашето устройство, като търси сума във вашата валута. Нищо не се запазва и не се изпраща никъде, докато сами не го запишете. + Достъп до известията + Android изисква вашето разрешение, за да може Minus да чете известия. Можете да го оттеглите по всяко време. + Отваряне на достъпа до известия + Следене на известията + Включва напомнянето. Minus разглежда само избраните по-долу приложения и пренебрегва всички други известия. + Приложения за следене + Няма приложение, което да съответства на търсенето. + Изберете поне едно приложение, например банковото, за да работи напомнянето. + Търсене на приложения + Избрани: %1$d + Напомняния за разходи + Напомняния да запишете плащане, което Minus е открил в известие на друго приложение. + Изглежда, че току-що похарчихте %1$s. Не забравяйте да го извадите. + Извади + Запази както е + Отмени + Запазено в Minus + %1$s е запазено без категория. + %1$s е запазено за следващия ви период. + възстановяване,възстановена,захранване,превод от,баланс,наличност,кешбек,otp,код за потвърждение,отстъпка,промоция + Папка за синхронизация + Изберете папка, в която да се пази копие в CSV + Синхронизира се към %1$s на всеки 2 часа + Все още не е извършвана синхронизация + Последна синхронизация %1$s, внесени редове: %2$s + Възстановено от папката %1$s + Последната синхронизация %1$s запази тези файлове, някои редове бяха отхвърлени: %2$s + Последната синхронизация се провали %1$s: %2$s + Достъпът до папката беше отнет, изберете папката отново + Ръководство за синхронизация с CSV папка + Как да четете и записвате данните на Minus от външен скрипт + Синхронизация с CSV папка + Minus може да пази копие на данните си в CSV в папка, която вие изберете, и да внася CSV файлове, които оставите в същата папка. Външните инструменти получават достъп за четене и запис, без да докосват базата данни директно.\n\nЛичната база данни остава източникът на истината. Папката съдържа огледало в CSV, а не файла на базата данни. + Настройка + Изберете папка по-горе. От този момент Minus синхронизира на всеки два часа и в папката никога не се изтрива нищо освен собствените му стари резервни копия.\n\nВсяко изпълнение първо внася, после изнася, така че файлът, който четете, винаги отразява това, което току-що сте изпратили.\n\n1. Внася всеки файл с име minus_import_*.csv, преименувайки го на .imported или .rejected след прочитане.\n2. Записва minus_synced.csv, като пази до него предишните пет експорта като датирани копия minus_backup-.\n\nAndroid групира фоновата работа по време на Doze, а минималният интервал за планиране е 15 минути, така че два часа е цел, а не гаранция. Използвайте ръчното задействане, когато ви трябва веднага. + adb команди + Изпращане на нови транзакции + Синхронизиране веднага, вместо да се чака + Прочитане на текущото състояние + Заменете пътя до папката с този, който сте избрали. Командата по-горе вече посочва пакета, с който се инсталира този билд, така че работи и за финална, и за бета версия.\n\nЗадействането извежда приложението на преден план; няма вариант без интерфейс. Не носи нито път до файл, нито URI, а само казва на приложението да прегледа папката, за която вече сте дали достъп, затова не изисква разрешение за хранилище.\n\nМожете също да насочите папката към директория на Syncthing или Dropbox и да пропуснете adb напълно. + Файловете, които Minus записва + minus_synced.csv носи пълното заглавие от 26 колони и се презаписва при всяко изпълнение. Предишните пет експорта се пазят до него като файлове minus_backup- с отбелязано време на замяната им, така че грешен експорт никога не ви оставя без копие.\n\nВ един експорт се срещат три вида редове, различими по първата колона:\n\n__META__ са настройките на бюджета и текущият период. Един ред, първи, ако присъства.\n__ARCHIVED__ е по един ред за всеки затворен бюджетен период.\nВремевият печат е транзакция.\n\nЧетец, който иска само транзакции, трябва да прескочи двата маркерни реда. Стойността current_period_id в реда __META__ е тази, която връщате в period_id при записване на нови транзакции.\n\nАко преинсталирате и насочите Minus към папка, която вече съдържа minus_synced.csv, той възстановява от този файл, вместо да го презапише. + Файлът, който вашият скрипт записва + Вашият файл трябва да се нарича minus_import_<каквото и да е>.csv. Minus игнорира всеки друг файл в папката, така че споделена папка остава непокътната.\n\nПарсерът чете заглавието, което вашият файл обявява, и приема всяка липсваща колона за празна, така че не ви трябват всичките 26. Това е пълен и валиден файл. + date е задължително, точно yyyy-MM-dd HH:mm. Всяка друга форма отхвърля реда.\namount е задължително, обикновено десетично число по-голямо от нула. Редовете на Minus са разходи.\ncategory е свободен текст по избор, съпоставя се по име и се създава, ако е нов. Празно води до Без категория.\nextra_note е свободен текст по избор. Добро място за id на транзакцията от източника.\nperiod_id е препоръчително, копира се от current_period_id в реда __META__. Празно означава 0.\nid трябва да се пропусне. Стойност, различна от нула, презаписва съществуваща транзакция.\nis_recurrent е 1 или 0. Ако е 1, задайте frequency на WEEKLY, BIWEEKLY или MONTHLY.\nis_credit е 1 или 0.\n\nРедовете, които парсерът отхвърля, се прескачат един по един; останалата част от файла пак се внася. + Правила, които да спазвате + Наименувайте входящите файлове minus_import_*.csv. Всичко останало се оставя непокътнато, включително minus_synced.csv и ротациите minus_backup-. Точно това прави безопасно да насочите синхронизацията към папка, която споделяте с други инструменти.\n\nНищо, което оставите, не се изтрива. След прочитане файлът се преименува на .imported или на .rejected, ако някой от редовете му не е успял да се разчете, като съдържанието се запазва, за да видите какво се е объркало. Нито едно от двете имена не се прочита отново.\n\nНикога не записвайте id, различно от нула. Редовете с id по-голямо от нула заменят транзакцията с това id; редовете с празно или нулево id се вмъкват като нови. Тъй като експортът носи истинските id на редовете, повторното изпращане на непроменен експорт презаписва, вместо да дублира. Не можете да използвате експорт като шаблон за нови редове, без първо да изчистите колоната id.\n\nЗадайте period_id. Сумите се изчисляват по датите, така че ред с period_id 0 пак се показва правилно навсякъде. По-тясната цена е, че не може да бъде отнесен към период и остава след почистването, което се изпълнява при изтриване на архивиран период.\n\nВнасянето не е идемпотентно. Minus вмъква всеки прочетен ред, без да проверява за равностойна транзакция. Преименуването след внасяне спира същият файл да бъде прочетен два пъти, но изпращането на същите редове под ново име ще ги преброи двойно, затова премахнете дублиранията спрямо minus_synced.csv, преди да записвате.\n\nСамо разходи. Суми нула или по-малко се отхвърлят. Приходите и общите суми на бюджета стоят в реда __META__, а пренаписването им задейства пълно възстановяване на настройките, затова приемайте този канал само за добавяне на транзакции. + Съпоставяне на поток Open Finance + Съпоставете датата на доставчика с date, преформатирана като yyyy-MM-dd HH:mm. Съпоставете сумата с amount като абсолютна стойност, пропускайки входящите постъпления. Съпоставете категорията на доставчика или собственото си правило с category, нормализирайки имената, иначе ще натрупате почти дублирани. Сложете id на транзакцията на доставчика в extra_note, за да остане видимо в приложението и да даде на вашето премахване на дублирания устойчива опора.\n\nДвучасов цикъл изглежда така:\n\n1. Изтеглете minus_synced.csv.\n2. Разчетете го, прескачайки редовете __META__ и __ARCHIVED__, и отбележете current_period_id.\n3. Вземете новите транзакции от доставчика. За всяка проверете дали изтегленият файл вече съдържа такава със същата сума във вашия времеви прозорец или със същото id на доставчика в extra_note.\n4. Запишете несъвпадащите в файл minus_import_ с четирите колони по-горе плюс period_id.\n5. Изпратете файла, после задействайте синхронизацията.\n\nСтъпка 3 е мястото, където трябва да се случи премахването на дублирания. Minus няма да го направи за вас. diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 2bb02818..c4ccdef7 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -18,7 +18,7 @@ Lege deine Hand für etwa eine Sekunde über den oberen Rand des Bildschirms, in der Nähe des Hörers, und alle Beträge werden unkenntlich. Decke ihn erneut ab, um sie wieder anzuzeigen. Einnahmen und Anpassungen Beginne den Betrag mit +, um Geld in diesen Zeitraum zurückzubuchen, etwa ein Gehalt, eine Erstattung oder einen Zahlungseingang. Beginne ihn mit -, um Geld abzuziehen. Die übrigen Tasten funktionieren als normaler Rechner für den Betrag. - Credit Card payment + Kreditkartenzahlung Markiere die Ausgabe als mit Kreditkarte bezahlt. Sie zählt weiterhin zu diesem Zeitraum, aber Minus führt deine Schulden separat, damit du sie vor deinem Stichtag begleichen kannst. Heute Gestern @@ -125,10 +125,10 @@ Ausgabe Unbenannte Ausgabe Wiederkehrende Ausgaben — %1$s - Hide subscriptions outside of the period - Show subscriptions outside of the period - Hide past period expenses - Show past period expenses + Abos außerhalb der Periode ausblenden + Abos außerhalb der Periode anzeigen + Ausgaben vergangener Perioden ausblenden + Ausgaben vergangener Perioden anzeigen Transaktion konnte nicht gelöscht werden Transaktion konnte nicht gespeichert werden Wiederkehrende Zahlung konnte nicht gelöscht werden @@ -143,10 +143,10 @@ Kategorie löschen Zusätzliche Notiz Notiz - Budget must be greater than 0 - No end date defined - No days defined - Budget must be for at least one day + Das Budget muss größer als 0 sein + Kein Enddatum festgelegt + Keine Tage festgelegt + Das Budget muss für mindestens einen Tag gelten Analytics @@ -158,18 +158,18 @@ %d Tage Total budget - Minimum spent - Maximum spent - Average per day spent - Days\nremaining + Geringste Ausgabe + Höchste Ausgabe + Durchschnitt pro Tag + Tage\nübrig - See spent and remaining budget at a glance - Countdown days remaining in budget period - Full budget overview with top category and days left - Heatmap calendar to see spending distribution - Current month heatmap calendar - Quick shortcut to add a new expense - Add new expense + Ausgegebenes und restliches Budget auf einen Blick + Zählt die verbleibenden Tage der Budgetperiode herunter + Vollständige Budgetübersicht mit Top-Kategorie und Resttagen + Heatmap-Kalender für die Verteilung deiner Ausgaben + Heatmap-Kalender des aktuellen Monats + Schnelle Abkürzung zum Anlegen einer neuen Ausgabe + Neue Ausgabe hinzufügen Add expense Spent @@ -177,24 +177,24 @@ Remaining Total Spent days left - Budget Overview - Spent per day + Budgetübersicht + Ausgaben pro Tag Daily Wöchentlich alle zwei Wochen monatlich Heute This week - This biweekly + Diese zwei Wochen This month Für morgen %1$s Für nächste Woche %1$s Für die nächsten zwei Wochen %1$s Für nächsten Monat %1$s - Daily Amount exceeded - Weekly Amount exceeded - Biweekly Amount exceeded - Monthly Amount exceeded + Tagesbetrag überschritten + Wochenbetrag überschritten + Zweiwochenbetrag überschritten + Monatsbetrag überschritten %1$s überschritten %1$s und %2$s überschritten %1$s, %2$s und %3$s überschritten @@ -236,46 +236,46 @@ %d Monate Total: %1$s - Welcome to Minus - Hi and welcome, lets start saving together. - A simple budget tracker that helps you decide how much you can spend each day, week, biweekly, or month. + Willkommen bei Minus + Hallo und willkommen, lass uns gemeinsam anfangen zu sparen. + Ein einfacher Budget-Tracker, der dir hilft zu entscheiden, wie viel du pro Tag, Woche, zwei Wochen oder Monat ausgeben kannst. Set a budget - Define your spending limit for any period. - Track expenses - Log what you spend and see what is left each day. - Recurring expenses - Add subscriptions and Minus will track them for you automatically. - Calculate on the fly - Your remaining budget updates in real time as you spend. + Lege dein Ausgabenlimit für einen beliebigen Zeitraum fest. + Ausgaben erfassen + Trage ein, was du ausgibst, und sieh jeden Tag, was übrig ist. + Wiederkehrende Ausgaben + Füge Abos hinzu und Minus verfolgt sie automatisch für dich. + Unterwegs rechnen + Dein Restbudget aktualisiert sich in Echtzeit, während du ausgibst. Sehen deine Analyse - Charts, heatmaps, and category breakdowns for any period. + Diagramme, Heatmaps und Kategorieauswertungen für jeden Zeitraum. Spend wisely - Over time, learn how much you can save and how much you can spend. + Mit der Zeit lernst du, wie viel du sparen und wie viel du ausgeben kannst. Continue Recurrent Privacy mode is active - All your financial values are currently blurred. + Alle deine Finanzwerte sind derzeit unscharf. Got it Privatsphäre-Modus aktiviert - Cover the top of the phone (where the camera sits) with your hand for about a second to turn it off. + Halte etwa eine Sekunde deine Hand über den oberen Rand des Telefons (dort, wo die Kamera sitzt), um es auszuschalten. Privatsphäre-Modus aktiviert Privatsphäre-Modus deaktiviert What\'s New - See the latest features and fixes in v%1$s + Die neuesten Funktionen und Korrekturen in v%1$s ansehen Feature Improvement Bug fix Version %1$s — %2$s Version %1$s - Released %1$s + Veröffentlicht %1$s Schließen - Previous version - Updated to: %1$s + Vorherige Version + Aktualisiert auf: %1$s Minus ist jetzt aktualisiert.<br>Für weitere Details besuchen Sie das <a href="https://github.com/isaacsa51/Minus/wiki">Wiki</a>. - Select the period - %1$d days · %2$s - %3$s - How do you want to view your budget? + Zeitraum auswählen + %1$d Tage — %2$s - %3$s + Wie möchtest du dein Budget sehen? Einstellungen Aussehen @@ -295,35 +295,35 @@ App Color Scheme Benachrichtigungen Features - App information + App-Informationen Data backup Tutorial Savings Theme - Change the app theme style - Theming and color - Change the color and appearance of the app + Den Theme-Stil der App ändern + Theme und Farbe + Farbe und Erscheinungsbild der App ändern Widgets Entdecke Widgets für deinen Startbildschirm Typography - Change the app typography style + Den Typografie-Stil der App ändern Abgerundeter Schriftstil Material You - Use wallpaper colors to apply a dynamic app theme + Hintergrundfarben für ein dynamisches App-Theme verwenden AMOLED-Design Reines Schwarz für Hintergründe und Flächen im dunklen Modus verwenden Privatsphäre-Modus - Hide and blur all financial values across the app - Use Credit Card as payment - Tap to see more details - When activated, you can now mark payments made with a credit card. \nBy declaring a billing date, you\'ll know how much to pay your bank before you have to pay interest, through a notification separate from the billing period. - Enable CC usage - Category behavior - Configure how the category picker behaves when tagging a transaction. - Show categories on tap - Open the suggestions popup with all your saved categories as soon as you tap the category field. When off, suggestions only appear after you start typing. - Show as a list (Experimental) - Tap the category tag to open a grid of your saved categories, instead of entering inline edit mode. Also turns off the numpad\'s calculation mode while this is on. + Alle Finanzwerte in der App verbergen und unscharf machen + Kreditkarte als Zahlungsmittel nutzen + Tippen für weitere Details + Wenn aktiviert, kannst du Zahlungen markieren, die mit Kreditkarte getätigt wurden. \nIndem du ein Abrechnungsdatum angibst, weißt du über eine von der Abrechnungsperiode getrennte Benachrichtigung, wie viel du deiner Bank zahlen musst, bevor Zinsen anfallen. + Kreditkartennutzung aktivieren + Verhalten der Kategorien + Lege fest, wie sich die Kategorieauswahl beim Markieren einer Transaktion verhält. + Kategorien beim Antippen zeigen + Öffnet das Vorschlagsfenster mit allen gespeicherten Kategorien, sobald du das Kategoriefeld antippst. Ist die Option aus, erscheinen Vorschläge erst beim Tippen. + Als Liste anzeigen (experimentell) + Tippe auf das Kategorie-Tag, um ein Raster deiner gespeicherten Kategorien zu öffnen, statt in den Inline-Bearbeitungsmodus zu wechseln. Schaltet außerdem den Rechenmodus des Ziffernblocks aus, solange die Option aktiv ist. Zusätzliche Notiz pro Transaktion Tippen, um mehr Details zu sehen Fügt beim Hinzufügen oder Bearbeiten einer Transaktion ein optionales Feld \"Zusätzliche Notiz\" neben der Kategorie hinzu. Damit kannst du dir ein Detail merken, das die Kategorie allein nicht erfasst – zum Beispiel, wofür das Geld in \"Pendeln\" genau ausgegeben wurde. @@ -345,96 +345,96 @@ Kategorievorschläge beim Tippen Kategorielisten-Modus Vergangene Zeiträume laden - Show past transactions - Load transactions from past periods - Period end time - The notification will be shown on the period end date at this time - Recurring payments time - Daily recurring payment checks run at this time - Notifications permission - Granted — you will receive notifications - Denied — tap to open system settings and allow notifications + Vergangene Transaktionen anzeigen + Transaktionen aus vergangenen Perioden laden + Uhrzeit des Periodenendes + Die Benachrichtigung erscheint am Enddatum der Periode zu dieser Uhrzeit + Uhrzeit wiederkehrender Zahlungen + Die tägliche Prüfung wiederkehrender Zahlungen läuft zu dieser Uhrzeit + Benachrichtigungsberechtigung + Erteilt — du erhältst Benachrichtigungen + Verweigert — tippe, um die Systemeinstellungen zu öffnen und Benachrichtigungen zu erlauben Open Exact alarms - Enabled to try showing notifications at the selected time - Disabled; Android may delay the notification + Aktiviert, damit Benachrichtigungen möglichst zur gewählten Zeit erscheinen + Deaktiviert; Android kann die Benachrichtigung verzögern Abbrechen Ok About - Learn more about the app and its development + Mehr über die App und ihre Entwicklung erfahren Found a bug? - Create a bug report and we will fix it soon + Erstelle einen Fehlerbericht und wir beheben es bald Wozu ist das gut? Version - App and device metadata copied to clipboard + App- und Gerätedaten in die Zwischenablage kopiert Feedback & Support Report type Bug Report - Feature Request + Funktionswunsch Title - Summarize the issue or request… - A title is required to submit. + Fasse das Problem oder den Wunsch zusammen… + Ein Titel ist zum Absenden erforderlich. Description - What happened? What did you expect to happen? - A description is required to submit. + Was ist passiert? Was hast du erwartet? + Eine Beschreibung ist zum Absenden erforderlich. Problem - What problem or workflow would this improve? - A problem description is required. - Proposed solution - Describe the change you would like to see… - A proposed solution is required for feature requests. - Alternatives considered - Optional: other ways this could work… - Additional context - Logs, exported data details, recurrence/budget period setup, or anything else useful. - Any extra info if necessary + Welches Problem oder welchen Ablauf würde das verbessern? + Eine Problembeschreibung ist erforderlich. + Vorgeschlagene Lösung + Beschreibe die Änderung, die du dir wünschst… + Für Funktionswünsche ist eine vorgeschlagene Lösung erforderlich. + Erwogene Alternativen + Optional: andere Wege, wie das funktionieren könnte… + Zusätzlicher Kontext + Logs, Details zu exportierten Daten, Einrichtung von Wiederholung und Budgetperiode oder alles andere, was hilft. + Weitere Infos, falls nötig Attachments - Screenshots or recordings - Submit Report + Screenshots oder Aufnahmen + Bericht absenden Send report - Help us make Minus better - "Reporting issues or suggesting improvements helps us make Minus better for everyone.\n\nThis guided form helps you package all the necessary details into a report, automatically attaching device metadata and app diagnostics to speed up our investigation.\n\nOnce prepared, you can send the report via email or contribute via " + Hilf uns, Minus besser zu machen + "Fehler zu melden oder Verbesserungen vorzuschlagen hilft uns, Minus für alle besser zu machen.\n\nDieses geführte Formular hilft dir, alle nötigen Angaben in einem Bericht zu bündeln, und hängt Gerätedaten sowie App-Diagnosen automatisch an, um unsere Untersuchung zu beschleunigen.\n\nSobald er fertig ist, kannst du den Bericht per E-Mail senden oder über " " GitHub Issues " - directly on our repository. - Steps to reproduce - Include reproduction steps + direkt in unserem Repository. + Schritte zum Reproduzieren + Reproduktionsschritte einschließen Add Step - Open the wallet screen - Click on \'Transfer\' - Observe the crash… - Describe step %1$d… + Den Wallet-Bildschirm öffnen + Auf \'Überweisen\' klicken + Den Absturz beobachten… + Schritt %1$d beschreiben… %1$d. - Upload Assets - Screenshots or videos (up to 10MB) - %1$d attachment(s) selected + Dateien hochladen + Screenshots oder Videos (bis 10 MB) + %1$d Anhang/Anhänge ausgewählt Anhang entfernen Back up data - Back up data to minus_export.csv + Daten in minus_export.csv sichern Import CSV - Import data from a CSV file - Reset tutorial - You will see gesture hints, info, and actions across the app again. + Daten aus einer CSV-Datei importieren + Tutorial zurücksetzen + Du siehst Gestenhinweise, Infos und Aktionen in der App wieder. App theme Light - Always use light theme + Immer das helle Theme verwenden Dark - Always use dark theme - Follow system - Use the system theme setting - App typography + Immer das dunkle Theme verwenden + System folgen + Die Theme-Einstellung des Systems verwenden + App-Typografie Default - Use the base typography + Die Basis-Typografie verwenden Condensed - Use compact typography + Kompakte Typografie verwenden Expressive - Use expressive typography + Expressive Typografie verwenden Expand Collapse Budget amount Exceeded No budget - Set up your budget + Richte dein Budget ein %1$s wird abgezogen Tagesbudget Wochenbudget @@ -444,39 +444,39 @@ %1$s, %2$s und %3$s aufgebraucht Spent - Available: %1$s%% + Verfügbar: %1$s%% - Uncategorized + Ohne Kategorie Remaining - We can\'t split your expenses - Save expenses with a category to split them - No expenses in this category - No expenses on this day, yet… + Wir können deine Ausgaben nicht aufteilen + Speichere Ausgaben mit einer Kategorie, um sie aufzuteilen + Keine Ausgaben in dieser Kategorie + Noch keine Ausgaben an diesem Tag… - This chart shows money spent for each day in the period + Dieses Diagramm zeigt das ausgegebene Geld für jeden Tag der Periode Less More - No recorded expenses - Add an expense to get started + Keine erfassten Ausgaben + Füge eine Ausgabe hinzu, um zu starten - Upcoming recurrent payments - Recurrent Payments on this period + Anstehende wiederkehrende Zahlungen + Wiederkehrende Zahlungen in dieser Periode Heute Morgen In %1$d days - In %1$d weeks - Unnamed recurrent expense - Delete recurrent expense - this recurrent expense + In %1$d Wochen + Unbenannte wiederkehrende Ausgabe + Wiederkehrende Ausgabe löschen + diese wiederkehrende Ausgabe Are you sure you want to delete \"%1$s\"? - This action will remove the whole recurrent expense configuration and you will not receive more notifications. + Diese Aktion entfernt die gesamte Konfiguration der wiederkehrenden Ausgabe und du erhältst keine weiteren Benachrichtigungen. - Unnamed subscription - Weekly subscription without name - Biweekly subscription without name - Monthly subscription without name + Unbenanntes Abo + Wöchentliches Abo ohne Namen + Zweiwöchentliches Abo ohne Namen + Monatliches Abo ohne Namen Abonnements Wiederkehrend @@ -525,51 +525,51 @@ Füge den vollen Betrag %1$s nur dem heutigen Budget hinzu Restbudget verwerfen Verwirf den Restbetrag und gehe direkt zu deinen Auswertungen - Pending extra money - From a previous period - Tap to manage + Übriges Geld offen + Aus einer vorherigen Periode + Tippen zum Verwalten Zeitraum beendet - Huh? You didn\'t spend anything this period - Watch out! You went over budget in this period - Here are the stats for all your spending + Hm? Du hast in dieser Periode nichts ausgegeben + Achtung! Du hast in dieser Periode dein Budget überschritten + Hier sind die Zahlen zu all deinen Ausgaben - Savings recommendation - This %1$d/%2$d/%3$d method divides your budget into %1$d%% needs, %2$d%% wants, and %3$d%% savings, helping you control spending and save consistently. - Current savings - Available: %1$s - Ideal savings: %1$s - Current savings: %1$s - Recurring expenses - One-time expenses - If you save the %1$d%% each period for about 6 months, you could build: - To reach %1$s in %2$d months, save %3$s per period. - Showing the default %1$d%% example. Set a savings goal in Settings to target a specific amount. + Sparempfehlung + Diese %1$d/%2$d/%3$d-Methode teilt dein Budget in %1$d%% Bedarf, %2$d%% Wünsche und %3$d%% Sparen auf und hilft dir, Ausgaben zu steuern und regelmäßig zu sparen. + Aktuelle Ersparnisse + Verfügbar: %1$s + Ideale Ersparnisse: %1$s + Aktuelle Ersparnisse: %1$s + Wiederkehrende Ausgaben + Einmalige Ausgaben + Wenn du die %1$d%% etwa sechs Monate lang in jeder Periode sparst, könntest du aufbauen: + Um %1$s in %2$d Monaten zu erreichen, spare %3$s pro Periode. + Es wird das Standardbeispiel mit %1$d%% gezeigt. Lege in den Einstellungen ein Sparziel fest, um einen konkreten Betrag anzustreben. - Savings preferences - Customize the 50/30/20 split and set an optional savings goal + Spar-Einstellungen + Passe die 50/30/20-Aufteilung an und setze ein optionales Sparziel Budget split - The three values must add up to 100%%. - The three values must add up to 100%% (currently %1$d%%). + Die drei Werte müssen zusammen 100%% ergeben. + Die drei Werte müssen zusammen 100%% ergeben (aktuell %1$d%%). Balanced - 50/30/20 — the classic split - Aggressive saver - 40/20/40 — save more each period + 50/30/20 — die klassische Aufteilung + Konsequentes Sparen + 40/20/40 — spare in jeder Periode mehr Conservative - 70/20/10 — high needs, lower savings + 70/20/10 — hoher Bedarf, weniger Sparen Custom - %1$d/%2$d/%3$d — choose your own split + %1$d/%2$d/%3$d — wähle deine eigene Aufteilung Custom split - Needs — %1$d%% - Wants — %1$d%% - Savings — %1$d%% - Savings goal (optional) - Target amount - Target period (months) - Leave blank to keep the default %1$d%% × 6 example. - Reset to defaults + Bedarf — %1$d%% + Wünsche — %1$d%% + Sparen — %1$d%% + Sparziel (optional) + Zielbetrag + Zielzeitraum (Monate) + Leer lassen, um das Standardbeispiel %1$d%% — 6 zu behalten. + Auf Standard zurücksetzen - Show all expenses from the previous period… + Alle Ausgaben der vorherigen Periode anzeigen… Use %d days Currency @@ -584,10 +584,10 @@ Am Ende der Periode entscheidest du, was mit dem Rest passiert. Der Rest wird gleichmäßig auf alle Tage der nächsten Periode verteilt. Der gesamte Rest kommt auf den ersten Tag der nächsten Periode. - Select currency - Search currency… + Währung auswählen + Währung suchen… Clear - No currencies found + Keine Währungen gefunden Selected @@ -618,24 +618,24 @@ Häufigkeit Charge day Day %1$d - Recurrence end + Ende der Wiederholung TOTAL AMOUNT - Mark all as paid - Marked all as paid + Alle als bezahlt markieren + Alle als bezahlt markiert Mark as paid - Marked as paid + Als bezahlt markiert - Budget Period End - Notifications when your budget period ends with remaining balance - Recurring Expenses - Notifications when recurring expenses are due - Credit Card Reminders - Reminders before credit card cutoff date - Budget period finished - The period ended with %1$s remaining. Tap to see details. - The period ended. You spent %1$s extra. See more details. - The period ended. You didn\'t spend anything. See more details. + Ende der Budgetperiode + Benachrichtigungen, wenn deine Budgetperiode mit Restbetrag endet + Wiederkehrende Ausgaben + Benachrichtigungen, wenn wiederkehrende Ausgaben fällig sind + Kreditkarten-Erinnerungen + Erinnerungen vor dem Abrechnungsstichtag der Kreditkarte + Budgetperiode beendet + Die Periode endete mit %1$s Restbetrag. Tippen für Details. + Die Periode ist beendet. Du hast %1$s zu viel ausgegeben. Mehr Details ansehen. + Die Periode ist beendet. Du hast nichts ausgegeben. Mehr Details ansehen. Deine Zahlung für „%1$s“ ist heute fällig Deine wöchentliche Ausgabe von %1$s wird abgebucht. Deine zweiwöchentliche Ausgabe von %1$s wird abgebucht. @@ -643,22 +643,22 @@ tomorrow in %1$d days Anstehende Ausgabe - Your subscription \"%1$s\" of \"%2$s\" will be charged %3$s. Prepare for the charge. - You have a subscription of \"%1$s\" that will be charged %2$s. - Upcoming credit card payment + Dein Abo \"%1$s\" für \"%2$s\" wird %3$s abgebucht. Stell dich auf die Abbuchung ein. + Du hast ein Abo über \"%1$s\", das %2$s abgebucht wird. + Anstehende Kreditkartenzahlung Your cutoff date is %1$s. It is recommended to pay %2$s to avoid any interest. Total owed - Debt-Adjusted Balance - %1$s - %2$s credit debt + Schuldenbereinigter Saldo + %1$s - %2$s Kreditkartenschuld CREDIT - %1$s — Cutoff in %2$d days - %1$s — Cutoff today + %1$s — Stichtag in %2$d Tagen + %1$s — Stichtag heute EXPENSE RECURRENT EXPENSE Past periods Tippe auf einen Zeitraum für die vollständige Aufschlüsselung. Lange drücken, um ihn zu löschen. - No archived periods found + Keine archivierten Perioden gefunden Spent Limit On Track @@ -668,24 +668,24 @@ Saved %1$s Gespartes Geld - Spending Trends - These cards show your lowest and highest expenses.\nAs you add more transactions, these will update to help you spot outliers. + Ausgabentrends + Diese Karten zeigen deine niedrigsten und höchsten Ausgaben.\nJe mehr Transaktionen du hinzufügst, desto besser helfen sie dir, Ausreißer zu erkennen. Periodenverlauf Hinter diesem Symbol liegen alle abgeschlossenen Zeiträume. Tippe auf einen, um seine vollständige Auswertung erneut zu öffnen; die Summen werden beim Lesen neu berechnet, eine Änderung an einer alten Ausgabe aktualisiert also den zugehörigen Zeitraum. Halte einen Zeitraum gedrückt und bestätige mit deinem Fingerabdruck, um ihn endgültig zu löschen. - Budget Status - Check your total budget and see it\'s total days. - Spending Intensity - This calendar shows when you spend the most. Darker days mean higher spending. Tap on any day to see all expenses registered for that specific date. - Category Breakdown - See which categories are eating up your budget. Tap any slice or category name for details. - Savings Recommendation - Based on the 50/30/20 rule, we suggest how much you should ideally be saving. This can be changed on settings. + Budgetstatus + Prüfe dein Gesamtbudget und sieh dir die gesamte Anzahl der Tage an. + Ausgabenintensität + Dieser Kalender zeigt, wann du am meisten ausgibst. Dunklere Tage bedeuten höhere Ausgaben. Tippe auf einen Tag, um alle für dieses Datum erfassten Ausgaben zu sehen. + Aufteilung nach Kategorien + Sieh, welche Kategorien dein Budget auffressen. Tippe auf ein Segment oder einen Kategorienamen für Details. + Sparempfehlung + Auf Basis der 50/30/20-Regel schlagen wir vor, wie viel du idealerweise sparen solltest. Das kann in den Einstellungen geändert werden. Noch kein Budgetzeitraum Erstelle einen Budgetzeitraum, dann erscheinen hier deine Ausgaben, Kategorien und Ersparnisse. - Credit Card Management - Tap the credit card to view all CC transactions, mark them as paid, or adjust your billing cutoff date to avoid interest. - Transaction History - Tap here to see a list of all your expenses for this period. + Kreditkartenverwaltung + Tippe auf die Kreditkarte, um alle Kreditkartenbuchungen zu sehen, sie als bezahlt zu markieren oder deinen Abrechnungsstichtag anzupassen, um Zinsen zu vermeiden. + Transaktionsverlauf + Tippe hier, um eine Liste aller deiner Ausgaben für diese Periode zu sehen. Skip Spending Current @@ -738,4 +738,60 @@ Alles, was sich wiederholt, mit den Kosten pro Zeitraum. Öffne es, um das nächste Fälligkeitsdatum zu sehen oder eine Serie zu beenden, die du nicht mehr zahlst. Zurückgebuchtes Geld Alles, was du mit einem führenden + erfasst hast – ein Gehalt, eine Erstattung, ein Zahlungseingang. Es wird diesem Zeitraum gutgeschrieben, statt als Ausgabe zu zählen, dein verbleibendes Budget steigt also. + Benachrichtigungen + Experimentell + Experimentell + Ausgaben-Erinnerungen aus Benachrichtigungen + Beobachtet die Benachrichtigungen der von dir ausgewählten Apps und erinnert dich daran, eine Buchung in Minus zu erfassen, wenn eine danach aussieht. + Ausgaben-Erinnerungen + Minus liest nur die Benachrichtigungen der von dir ausgewählten Apps, ausschließlich auf deinem Gerät, und sucht darin einen Betrag in deiner Währung. Nichts wird gespeichert oder irgendwohin gesendet, bis du es selbst speicherst. + Benachrichtigungszugriff + Android benötigt deine Erlaubnis, bevor Minus Benachrichtigungen lesen kann. Du kannst sie jederzeit widerrufen. + Benachrichtigungszugriff öffnen + Benachrichtigungen beobachten + Schaltet die Erinnerung ein. Minus sieht sich nur die unten ausgewählten Apps an und ignoriert jede andere Benachrichtigung. + Zu beobachtende Apps + Keine App passt zu deiner Suche. + Wähle mindestens eine App, zum Beispiel die deiner Bank, damit die Erinnerung etwas tut. + Apps suchen + %1$d ausgewählt + Ausgaben-Erinnerungen + Erinnerungen, eine Buchung zu erfassen, die Minus in der Benachrichtigung einer anderen App gefunden hat. + Sieht aus, als hättest du gerade %1$s ausgegeben. Vergiss nicht, es abzuziehen. + Abziehen + So speichern + Rückgängig + In Minus gespeichert + %1$s ohne Kategorie gespeichert. + %1$s für deine nächste Periode gespeichert. + erstattung,gutschrift,gutgeschrieben,einzahlung,kontostand,guthaben,cashback,otp,verifizierungscode,rabatt,sonderangebot + Sync-Ordner + Wähle einen Ordner für eine CSV-Kopie + Synchronisiert alle 2 Stunden nach %1$s + Es wurde noch keine Synchronisierung ausgeführt + Letzte Synchronisierung %1$s, %2$s Zeilen importiert + Aus dem Ordner %1$s wiederhergestellt + Die letzte Synchronisierung %1$s hat diese Dateien behalten, einige Zeilen wurden abgelehnt: %2$s + Letzte Synchronisierung fehlgeschlagen %1$s: %2$s + Der Ordnerzugriff wurde entzogen, wähle den Ordner erneut + Anleitung zur CSV-Ordnersynchronisierung + Wie du Minus-Daten aus einem externen Skript liest und schreibst + CSV-Ordnersynchronisierung + Minus kann eine CSV-Kopie seiner Daten in einem Ordner deiner Wahl vorhalten und CSV-Dateien importieren, die du in denselben Ordner legst. Externe Werkzeuge erhalten Lese- und Schreibzugriff, ohne die Datenbank direkt anzufassen.\n\nDie private Datenbank bleibt die Quelle der Wahrheit. Der Ordner enthält eine CSV-Spiegelung, nicht die Datenbankdatei. + Einrichtung + Wähle oben einen Ordner. Ab dann synchronisiert Minus alle zwei Stunden, und im Ordner wird nie etwas gelöscht außer seinen eigenen alten Backups.\n\nJeder Durchlauf importiert zuerst und exportiert dann, sodass die Datei, die du liest, immer das widerspiegelt, was du gerade übertragen hast.\n\n1. Importiert jede Datei mit dem Namen minus_import_*.csv und benennt sie nach dem Lesen in .imported oder .rejected um.\n2. Schreibt minus_synced.csv und behält die fünf vorherigen Exporte daneben als datierte minus_backup--Kopien.\n\nAndroid bündelt Hintergrundarbeit während des Doze-Modus und die Planungsuntergrenze liegt bei 15 Minuten, zwei Stunden sind also ein Ziel und keine Garantie. Nutze den manuellen Auslöser, wenn du es sofort brauchst. + adb-Befehle + Neue Buchungen übertragen + Sofort synchronisieren statt zu warten + Den aktuellen Stand lesen + Ersetze den Ordnerpfad durch den, den du gewählt hast. Der Befehl oben nennt bereits das Paket, unter dem dieser Build installiert wird, er funktioniert also sowohl für eine Release- als auch für eine Beta-Version.\n\nDer Auslöser holt die App in den Vordergrund; eine Variante ohne Oberfläche gibt es nicht. Er trägt weder einen Dateipfad noch eine URI, sondern sagt der App nur, sie soll den Ordner durchsuchen, für den du bereits Zugriff gewährt hast, deshalb braucht er keine Speicherberechtigung.\n\nDu kannst den Ordner auch auf ein Syncthing- oder Dropbox-Verzeichnis zeigen lassen und adb ganz weglassen. + Die Dateien, die Minus schreibt + minus_synced.csv trägt die vollständige Kopfzeile mit 26 Spalten und wird bei jedem Durchlauf neu geschrieben. Die fünf vorherigen Exporte bleiben daneben als minus_backup--Dateien mit dem Zeitpunkt ihrer Ersetzung, sodass ein fehlerhafter Export dich nie ohne Kopie zurücklässt.\n\nIn einem Export erscheinen drei Arten von Zeilen, unterscheidbar an der ersten Spalte:\n\n__META__ sind die Budgeteinstellungen und die aktuelle Periode. Eine Zeile, als erste, falls vorhanden.\n__ARCHIVED__ ist eine Zeile pro abgeschlossener Budgetperiode.\nEin Zeitstempel ist eine Buchung.\n\nEin Leser, der nur Buchungen will, muss die beiden Markerzeilen überspringen. Der Wert current_period_id in der __META__-Zeile ist der, den du beim Schreiben neuer Buchungen in period_id zurückgibst.\n\nWenn du neu installierst und Minus auf einen Ordner zeigen lässt, der bereits eine minus_synced.csv enthält, stellt es aus dieser Datei wieder her, statt sie zu überschreiben. + Die Datei, die dein Skript schreibt + Deine Datei muss minus_import_<beliebig>.csv heißen. Minus ignoriert jede andere Datei im Ordner, ein gemeinsam genutzter Ordner bleibt also unberührt.\n\nDer Parser liest die Kopfzeile, die deine Datei deklariert, und behandelt jede fehlende Spalte als leer, du brauchst also nicht alle 26. Das hier ist eine vollständige, gültige Datei. + date ist erforderlich, genau yyyy-MM-dd HH:mm. Jede andere Form verwirft die Zeile.\namount ist erforderlich, eine einfache Dezimalzahl größer als null. Minus-Zeilen sind Ausgaben.\ncategory ist optionaler Freitext, wird per Name zugeordnet und neu angelegt, wenn unbekannt. Leer fällt auf Uncategorized zurück.\nextra_note ist optionaler Freitext. Guter Platz für die Buchungs-ID des Ursprungssystems.\nperiod_id ist empfohlen, kopiert aus current_period_id in der __META__-Zeile. Leer bedeutet 0.\nid sollte weggelassen werden. Ein Wert ungleich null überschreibt eine bestehende Buchung.\nis_recurrent ist 1 oder 0. Bei 1 setze frequency auf WEEKLY, BIWEEKLY oder MONTHLY.\nis_credit ist 1 oder 0.\n\nZeilen, die der Parser ablehnt, werden einzeln übersprungen; der Rest der Datei wird trotzdem importiert. + Regeln, die du einhalten musst + Nenne eingehende Dateien minus_import_*.csv. Alles andere wird nicht angetastet, auch minus_synced.csv und die minus_backup--Rotationen. Genau das macht es sicher, die Synchronisierung auf einen Ordner zu richten, den du mit anderen Werkzeugen teilst.\n\nNichts, was du dort ablegst, wird gelöscht. Nach dem Lesen wird eine Datei in .imported umbenannt, oder in .rejected, wenn eine ihrer Zeilen nicht geparst werden konnte, wobei der Inhalt erhalten bleibt, damit du siehst, was schiefging. Keiner der beiden Namen wird erneut eingelesen.\n\nSchreibe niemals eine id ungleich null. Zeilen mit einer id größer als null ersetzen die Buchung mit dieser id; Zeilen mit leerer id oder id null werden als neu eingefügt. Da der Export echte Zeilen-IDs trägt, überschreibt ein erneutes Übertragen eines unveränderten Exports statt zu duplizieren. Du kannst einen Export nicht als Vorlage für neue Zeilen verwenden, ohne zuerst die Spalte id zu leeren.\n\nSetze period_id. Summen werden aus den Daten berechnet, eine Zeile mit period_id 0 wird also überall korrekt angezeigt. Der engere Preis ist, dass sie keiner Periode zuzuordnen ist und die Aufräumarbeit überlebt, die beim Löschen einer archivierten Periode läuft.\n\nDer Import ist nicht idempotent. Minus fügt jede gelesene Zeile ein, ohne auf eine gleichwertige Buchung zu prüfen. Das Umbenennen nach dem Import verhindert, dass dieselbe Datei zweimal gelesen wird, aber dieselben Zeilen unter einem neuen Namen zu übertragen zählt sie doppelt, dedupliziere also gegen minus_synced.csv, bevor du schreibst.\n\nNur Ausgaben. Beträge von null oder weniger werden abgelehnt. Einnahmen und Budgetsummen stehen in der __META__-Zeile, und sie neu zu schreiben löst eine vollständige Wiederherstellung der Einstellungen aus, behandle diesen Kanal für Buchungen also als rein anfügend. + Einen Open-Finance-Feed abbilden + Bilde das Datum des Anbieters auf date ab, umformatiert zu yyyy-MM-dd HH:mm. Bilde den Betrag als Absolutwert auf amount ab und überspringe Zuflüsse. Bilde die Kategorie des Anbieters, oder deine eigene Regel, auf category ab und normalisiere die Namen, sonst sammelst du Beinahe-Duplikate an. Schreibe die Buchungs-ID des Anbieters in extra_note, damit sie in der App sichtbar bleibt und deiner Deduplizierung einen stabilen Anker gibt.\n\nEin Zwei-Stunden-Zyklus sieht so aus:\n\n1. Hole minus_synced.csv.\n2. Parse sie, überspringe die Zeilen __META__ und __ARCHIVED__ und notiere current_period_id.\n3. Hole neue Buchungen beim Anbieter. Prüfe für jede, ob die geholte Datei schon eine mit demselben Betrag innerhalb deines Zeitfensters enthält, oder mit derselben Anbieter-ID in extra_note.\n4. Schreibe die nicht zugeordneten in eine minus_import_-Datei mit den vier Spalten von oben plus period_id.\n5. Übertrage die Datei und löse dann die Synchronisierung aus.\n\nSchritt 3 ist der Punkt, an dem die Deduplizierung passieren muss. Minus übernimmt das nicht für dich. diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index bd60fe5a..51607adb 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -103,7 +103,7 @@ Ολοκλήρωση της περιόδου προϋπολογισμού νωρίς Αυτό θα κλείσει την τρέχουσα περίοδο και θα ξεκινήσει μια νέα. Είσαι σίγουρος? Ολοκλήρωση - You have %1$s left unspent. You\'ll be asked what to do with it when this period ends. + Σας έχουν μείνει %1$s αχρησιμοποίητα. Όταν λήξει αυτή η περίοδος, θα ερωτηθείτε τι θέλετε να κάνετε με αυτά. Χωρίς ημερομηνία λήξης Πώς θέλετε να χωρίσετε τον προϋπολογισμό? Νέα Περίοδος Προϋπολογισμού @@ -738,4 +738,60 @@ Ό,τι επαναλαμβάνεται, με το κόστος ανά περίοδο. Ανοίξτε το για να δείτε την επόμενη ημερομηνία χρέωσης για καθένα ή για να σταματήσετε μια σειρά που δεν πληρώνετε πια. Χρήματα που επιστράφηκαν Ό,τι καταχωρίσατε ξεκινώντας με + — μισθός, επιστροφή χρημάτων, εισερχόμενο έμβασμα. Προστίθεται σε αυτήν την περίοδο αντί να μετρά ως δαπάνη, οπότε ο υπόλοιπος προϋπολογισμός σας αυξάνεται. + Ειδοποιήσεις + Πειραματικό + Πειραματικό + Υπενθυμίσεις εξόδων από ειδοποιήσεις + Παρακολουθεί τις ειδοποιήσεις των εφαρμογών που επιλέγεις και, όταν κάποια μοιάζει με χρέωση, σου θυμίζει να την καταγράψεις στο Minus. + Υπενθυμίσεις εξόδων + Το Minus διαβάζει τις ειδοποιήσεις μόνο των εφαρμογών που επιλέγεις και μόνο στη συσκευή σου, αναζητώντας ένα ποσό στο νόμισμά σου. Τίποτα δεν αποθηκεύεται ούτε στέλνεται κάπου μέχρι να το αποθηκεύσεις εσύ. + Πρόσβαση στις ειδοποιήσεις + Το Android χρειάζεται την άδειά σου για να μπορεί το Minus να διαβάζει ειδοποιήσεις. Μπορείς να την ανακαλέσεις οποτεδήποτε. + Άνοιγμα πρόσβασης ειδοποιήσεων + Παρακολούθηση ειδοποιήσεων + Ενεργοποιεί την υπενθύμιση. Το Minus βλέπει μόνο τις εφαρμογές που επιλέγονται παρακάτω και αγνοεί κάθε άλλη ειδοποίηση. + Εφαρμογές για παρακολούθηση + Καμία εφαρμογή δεν ταιριάζει με την αναζήτησή σου. + Επίλεξε τουλάχιστον μία εφαρμογή, όπως της τράπεζάς σου, για να κάνει κάτι η υπενθύμιση. + Αναζήτηση εφαρμογών + Επιλεγμένες: %1$d + Υπενθυμίσεις εξόδων + Υπενθυμίσεις για να καταγράψεις μια χρέωση που εντόπισε το Minus στην ειδοποίηση άλλης εφαρμογής. + Φαίνεται ότι μόλις ξόδεψες %1$s. Μην ξεχάσεις να το αφαιρέσεις. + Αφαίρεσέ το + Αποθήκευση ως είναι + Αναίρεση + Αποθηκεύτηκε στο Minus + Το %1$s αποθηκεύτηκε χωρίς κατηγορία. + Το %1$s αποθηκεύτηκε για την επόμενη περίοδό σου. + επιστροφή,πίστωση,πιστώθηκε,κατάθεση,υπόλοιπο,cashback,otp,κωδικός επαλήθευσης,έκπτωση,προσφορά + Φάκελος συγχρονισμού + Επιλέξτε φάκελο για να κρατάτε ένα αντίγραφο CSV + Συγχρονισμός στο %1$s κάθε 2 ώρες + Δεν έχει εκτελεστεί ακόμη συγχρονισμός + Τελευταίος συγχρονισμός %1$s, εισήχθησαν %2$s γραμμές + Επαναφορά από τον φάκελο %1$s + Ο τελευταίος συγχρονισμός %1$s κράτησε αυτά τα αρχεία, ορισμένες γραμμές απορρίφθηκαν: %2$s + Ο τελευταίος συγχρονισμός απέτυχε %1$s: %2$s + Η πρόσβαση στον φάκελο ανακλήθηκε, επιλέξτε ξανά τον φάκελο + Οδηγός συγχρονισμού φακέλου CSV + Πώς να διαβάζετε και να γράφετε τα δεδομένα του Minus από εξωτερικό script + Συγχρονισμός φακέλου CSV + Το Minus μπορεί να κρατά ένα αντίγραφο των δεδομένων του σε CSV μέσα σε έναν φάκελο που επιλέγετε εσείς και να εισάγει τα αρχεία CSV που αφήνετε στον ίδιο φάκελο. Τα εξωτερικά εργαλεία αποκτούν πρόσβαση ανάγνωσης και εγγραφής χωρίς να αγγίζουν απευθείας τη βάση δεδομένων.\n\nΗ ιδιωτική βάση δεδομένων παραμένει η πηγή αλήθειας. Ο φάκελος περιέχει ένα αντίγραφο σε CSV, όχι το αρχείο της βάσης δεδομένων. + Ρύθμιση + Επιλέξτε έναν φάκελο παραπάνω. Από εκείνη τη στιγμή το Minus συγχρονίζει κάθε δύο ώρες, και τίποτα στον φάκελο δεν διαγράφεται ποτέ εκτός από τα δικά του παλιά αντίγραφα ασφαλείας.\n\nΚάθε εκτέλεση πρώτα εισάγει και μετά εξάγει, ώστε το αρχείο που διαβάζετε να αντικατοπτρίζει πάντα αυτό που μόλις στείλατε.\n\n1. Εισάγει κάθε αρχείο με όνομα minus_import_*.csv, μετονομάζοντάς το σε .imported ή .rejected μόλις διαβαστεί.\n2. Γράφει το minus_synced.csv, κρατώντας δίπλα τις πέντε προηγούμενες εξαγωγές ως αντίγραφα minus_backup- με ημερομηνία.\n\nΤο Android ομαδοποιεί την εργασία στο παρασκήνιο κατά το Doze και το κατώτατο όριο χρονοπρογραμματισμού είναι 15 λεπτά, άρα οι δύο ώρες είναι στόχος και όχι εγγύηση. Χρησιμοποιήστε τη χειροκίνητη εκκίνηση όταν το χρειάζεστε αμέσως. + Εντολές adb + Αποστολή νέων συναλλαγών + Άμεσος συγχρονισμός αντί για αναμονή + Ανάγνωση της τρέχουσας κατάστασης + Αντικαταστήστε τη διαδρομή του φακέλου με αυτήν που επιλέξατε. Η εντολή παραπάνω ονομάζει ήδη το πακέτο με το οποίο εγκαθίσταται αυτό το build, άρα δουλεύει είτε τρέχετε έκδοση release είτε beta.\n\nΗ εκκίνηση φέρνει την εφαρμογή στο προσκήνιο· δεν υπάρχει παραλλαγή χωρίς διεπαφή. Δεν μεταφέρει καμία διαδρομή αρχείου ούτε URI, λέει μόνο στην εφαρμογή να σαρώσει τον φάκελο για τον οποίο έχετε ήδη δώσει πρόσβαση, γι’ αυτό δεν χρειάζεται άδεια αποθήκευσης.\n\nΜπορείτε επίσης να στρέψετε τον φάκελο σε έναν κατάλογο Syncthing ή Dropbox και να παρακάμψετε εντελώς το adb. + Τα αρχεία που γράφει το Minus + Το minus_synced.csv φέρει την πλήρη κεφαλίδα 26 στηλών και ξαναγράφεται σε κάθε εκτέλεση. Οι πέντε προηγούμενες εξαγωγές κρατούνται δίπλα ως αρχεία minus_backup- με σφραγίδα της ώρας που αντικαταστάθηκαν, ώστε μια κακή εξαγωγή να μη σας αφήνει ποτέ χωρίς αντίγραφο.\n\nΣε μια εξαγωγή εμφανίζονται τρία είδη γραμμής, που ξεχωρίζουν από την πρώτη στήλη:\n\n__META__ είναι οι ρυθμίσεις του προϋπολογισμού και η τρέχουσα περίοδος. Μία γραμμή, πρώτη εφόσον υπάρχει.\n__ARCHIVED__ είναι μία γραμμή για κάθε κλειστή περίοδο προϋπολογισμού.\nΜια χρονοσφραγίδα είναι μια συναλλαγή.\n\nΈνας αναγνώστης που θέλει μόνο συναλλαγές πρέπει να προσπεράσει τις δύο γραμμές δείκτες. Η τιμή current_period_id στη γραμμή __META__ είναι αυτή που επιστρέφετε στο period_id όταν γράφετε νέες συναλλαγές.\n\nΑν επανεγκαταστήσετε και στρέψετε το Minus σε φάκελο που περιέχει ήδη ένα minus_synced.csv, κάνει επαναφορά από εκείνο το αρχείο αντί να το αντικαταστήσει. + Το αρχείο που γράφει το script σας + Το αρχείο σας πρέπει να ονομάζεται minus_import_<οτιδήποτε>.csv. Το Minus αγνοεί κάθε άλλο αρχείο στον φάκελο, άρα ένας κοινόχρηστος φάκελος μένει ανέπαφος.\n\nΟ αναλυτής διαβάζει όποια κεφαλίδα δηλώνει το αρχείο σας και θεωρεί κάθε απούσα στήλη κενή, άρα δεν χρειάζεστε και τις 26. Αυτό είναι ένα πλήρες, έγκυρο αρχείο. + Το date είναι υποχρεωτικό, ακριβώς yyyy-MM-dd HH:mm. Κάθε άλλη μορφή απορρίπτει τη γραμμή.\nΤο amount είναι υποχρεωτικό, ένας απλός δεκαδικός μεγαλύτερος του μηδενός. Οι γραμμές του Minus είναι έξοδα.\nΤο category είναι προαιρετικό ελεύθερο κείμενο, αντιστοιχίζεται με το όνομα και δημιουργείται αν είναι νέο. Κενό καταλήγει σε Αταξινόμητο.\nΤο extra_note είναι προαιρετικό ελεύθερο κείμενο. Καλό σημείο για το id της συναλλαγής από την πηγή.\nΤο period_id συνιστάται, αντιγραμμένο από το current_period_id στη γραμμή __META__. Κενό σημαίνει 0.\nΤο id πρέπει να παραλείπεται. Τιμή διάφορη του μηδενός αντικαθιστά μια υπάρχουσα συναλλαγή.\nΤο is_recurrent είναι 1 ή 0. Αν είναι 1, ορίστε το frequency σε WEEKLY, BIWEEKLY ή MONTHLY.\nΤο is_credit είναι 1 ή 0.\n\nΟι γραμμές που απορρίπτει ο αναλυτής προσπερνιούνται μία προς μία· το υπόλοιπο αρχείο εισάγεται κανονικά. + Κανόνες που πρέπει να σεβαστείτε + Ονομάστε τα εισερχόμενα αρχεία minus_import_*.csv. Οτιδήποτε άλλο μένει ανέπαφο, συμπεριλαμβανομένων του minus_synced.csv και των εναλλαγών minus_backup-. Αυτό είναι που κάνει ασφαλές να στρέψετε τον συγχρονισμό σε φάκελο που μοιράζεστε με άλλα εργαλεία.\n\nΤίποτα από όσα αφήνετε δεν διαγράφεται. Αφού διαβαστεί, ένα αρχείο μετονομάζεται σε .imported, ή σε .rejected αν κάποια από τις γραμμές του απέτυχε να αναλυθεί, διατηρώντας το περιεχόμενό του για να δείτε τι πήγε λάθος. Κανένα από τα δύο ονόματα δεν ξαναδιαβάζεται.\n\nΜην γράφετε ποτέ id διάφορο του μηδενός. Οι γραμμές με id μεγαλύτερο του μηδενός αντικαθιστούν τη συναλλαγή που έχει αυτό το id· οι γραμμές με κενό ή μηδενικό id εισάγονται ως νέες. Επειδή η εξαγωγή φέρει τα πραγματικά id των γραμμών, η επαναποστολή μιας αμετάβλητης εξαγωγής αντικαθιστά αντί να διπλασιάζει. Δεν μπορείτε να χρησιμοποιήσετε μια εξαγωγή ως πρότυπο για νέες γραμμές χωρίς να αδειάσετε πρώτα τη στήλη id.\n\nΟρίστε το period_id. Τα σύνολα υπολογίζονται από τις ημερομηνίες, άρα μια γραμμή με period_id 0 εμφανίζεται σωστά παντού. Το στενότερο κόστος είναι ότι δεν αποδίδεται σε περίοδο και επιβιώνει του καθαρισμού που εκτελείται όταν διαγράφεται μια αρχειοθετημένη περίοδος.\n\nΗ εισαγωγή δεν είναι ιδεοδύναμη. Το Minus εισάγει κάθε γραμμή που διαβάζει χωρίς να ελέγχει για αντίστοιχη συναλλαγή. Η μετονομασία μετά την εισαγωγή εμποδίζει το ίδιο αρχείο να διαβαστεί δύο φορές, αλλά η αποστολή των ίδιων γραμμών με νέο όνομα θα τις μετρήσει διπλά, γι’ αυτό αφαιρέστε τα διπλότυπα συγκρίνοντας με το minus_synced.csv πριν γράψετε.\n\nΜόνο έξοδα. Ποσά μηδέν ή μικρότερα απορρίπτονται. Τα έσοδα και τα σύνολα του προϋπολογισμού βρίσκονται στη γραμμή __META__, και η επανεγγραφή τους πυροδοτεί πλήρη επαναφορά των ρυθμίσεων, γι’ αυτό αντιμετωπίστε αυτό το κανάλι ως μόνο για προσθήκη συναλλαγών. + Αντιστοίχιση ροής Open Finance + Αντιστοιχίστε την ημερομηνία του παρόχου στο date, μορφοποιημένη ως yyyy-MM-dd HH:mm. Αντιστοιχίστε το ποσό στο amount ως απόλυτη τιμή, προσπερνώντας τις εισροές. Αντιστοιχίστε την κατηγορία του παρόχου, ή τον δικό σας κανόνα, στο category, κανονικοποιώντας τα ονόματα αλλιώς θα συγκεντρώσετε σχεδόν διπλότυπα. Βάλτε το id συναλλαγής του παρόχου στο extra_note ώστε να παραμένει ορατό στην εφαρμογή και να δίνει στη δική σας αφαίρεση διπλότυπων μια σταθερή λαβή.\n\nΈνας κύκλος ανά δύο ώρες μοιάζει κάπως έτσι:\n\n1. Κατεβάστε το minus_synced.csv.\n2. Αναλύστε το, προσπερνώντας τις γραμμές __META__ και __ARCHIVED__, και σημειώστε το current_period_id.\n3. Φέρτε τις νέες συναλλαγές του παρόχου. Για κάθε μία, ελέγξτε αν το αρχείο που κατεβάσατε περιέχει ήδη μία με το ίδιο ποσό μέσα στο χρονικό σας παράθυρο, ή το ίδιο id παρόχου στο extra_note.\n4. Γράψτε όσες δεν ταιριάζουν σε ένα αρχείο minus_import_ με τις τέσσερις στήλες παραπάνω συν το period_id.\n5. Στείλτε το αρχείο και μετά πυροδοτήστε τον συγχρονισμό.\n\nΤο βήμα 3 είναι εκεί όπου πρέπει να γίνει η αφαίρεση διπλότυπων. Το Minus δεν θα το κάνει για εσάς. diff --git a/app/src/main/res/values-es-rMX/strings.xml b/app/src/main/res/values-es-rMX/strings.xml index b9f35a13..7262094d 100644 --- a/app/src/main/res/values-es-rMX/strings.xml +++ b/app/src/main/res/values-es-rMX/strings.xml @@ -738,4 +738,60 @@ Todo lo que se repite, con lo que te cuesta por período. Ábrelo para ver la próxima fecha de cobro de cada uno o para detener una serie que ya no pagas. Dinero devuelto Todo lo que registraste empezando con + — una nómina, un reembolso, una transferencia recibida. Se suma a este período en lugar de contar como gasto, así que tu presupuesto restante aumenta. + Notificaciones + Experimental + Experimental + Recordatorios de gasto por notificación + Observa las notificaciones de las apps que elijas y, cuando alguna parece un cargo, te recuerda registrarlo en Minus. + Recordatorios de gasto + Minus lee las notificaciones de las apps que elijas, solo en tu dispositivo, y busca un monto en tu moneda. Nada se guarda ni se envía a ningún lado hasta que tú lo guardes. + Acceso a las notificaciones + Android necesita tu permiso antes de que Minus pueda leer notificaciones. Puedes quitarlo en cualquier momento. + Abrir acceso a notificaciones + Observar notificaciones + Activa el recordatorio. Minus solo mira las apps seleccionadas abajo e ignora cualquier otra notificación. + Apps a observar + Ninguna app coincide con tu búsqueda. + Elige al menos una app, como la de tu banco, para que el recordatorio haga algo. + Buscar apps + %1$d seleccionadas + Recordatorios de gasto + Recordatorios para registrar un cargo que Minus detectó en la notificación de otra app. + Parece que acabas de gastar %1$s. No olvides restarlo. + Restarlo + Guardar así + Deshacer + Guardado en Minus + %1$s guardado sin categoría. + %1$s guardado para tu próximo período. + reembolso,devolución,abonado,abono de,depósito,saldo,cashback,otp,código de verificación,descuento,promoción + Carpeta de sincronización + Elige una carpeta donde guardar una copia en CSV + Sincronizando con %1$s cada 2 horas + Todavía no se ha ejecutado ninguna sincronización + Última sincronización %1$s, %2$s filas importadas + Restaurado desde la carpeta %1$s + La última sincronización %1$s conservó estos archivos; algunas filas se rechazaron: %2$s + La última sincronización falló %1$s: %2$s + Se revocó el acceso a la carpeta, vuelve a seleccionarla + Guía de sincronización por carpeta CSV + Cómo leer y escribir los datos de Minus desde un script externo + Sincronización por carpeta CSV + Minus puede mantener una copia en CSV de sus datos en una carpeta que elijas, e importar los archivos CSV que dejes en esa misma carpeta. Las herramientas externas obtienen acceso de lectura y escritura sin tocar la base de datos directamente.\n\nLa base de datos privada sigue siendo la fuente de verdad. La carpeta contiene un espejo en CSV, no el archivo de la base de datos. + Configuración + Elige una carpeta arriba. A partir de ese momento Minus sincroniza cada dos horas, y nunca se elimina nada de la carpeta salvo sus propias copias antiguas.\n\nCada ejecución importa primero y exporta después, así que el archivo que leas siempre refleja lo que acabas de enviar.\n\n1. Importa todos los archivos llamados minus_import_*.csv y renombra cada uno a .imported o .rejected una vez leído.\n2. Escribe minus_synced.csv y conserva junto a él las cinco exportaciones anteriores como copias minus_backup- con fecha.\n\nAndroid agrupa el trabajo en segundo plano durante el modo Doze y el mínimo de programación es de 15 minutos, así que dos horas es un objetivo y no una garantía. Usa el disparador manual cuando lo necesites al instante. + Comandos adb + Enviar transacciones nuevas + Sincronizar de inmediato en lugar de esperar + Leer el estado actual + Reemplaza la ruta de la carpeta por la que hayas elegido. El comando de arriba ya nombra el paquete con el que se instala esta compilación, así que funciona tanto en una versión estable como en una beta.\n\nEl disparador trae la app al primer plano; no hay variante sin interfaz. No lleva ninguna ruta de archivo ni URI, solo le dice a la app que revise la carpeta a la que ya le diste acceso, por lo que no necesita permiso de almacenamiento.\n\nTambién puedes apuntar la carpeta a un directorio de Syncthing o Dropbox y olvidarte por completo de adb. + Los archivos que escribe Minus + minus_synced.csv lleva el encabezado completo de 26 columnas y se reescribe en cada ejecución. Las cinco exportaciones anteriores se conservan junto a él como archivos minus_backup- marcados con la hora en que fueron reemplazadas, así que una exportación defectuosa nunca te deja sin copia.\n\nEn una exportación aparecen tres tipos de fila, distinguidos por la primera columna:\n\n__META__ son los ajustes del presupuesto y el período actual. Una sola fila, la primera si está presente.\n__ARCHIVED__ es una fila por cada período de presupuesto cerrado.\nUna marca de tiempo es una transacción.\n\nUn lector que solo quiera transacciones debe omitir las dos filas marcadoras. El valor current_period_id de la fila __META__ es el que debes devolver en period_id al escribir transacciones nuevas.\n\nSi reinstalas y apuntas Minus a una carpeta que ya contiene un minus_synced.csv, restaura desde ese archivo en lugar de sobrescribirlo. + El archivo que escribe tu script + Tu archivo debe llamarse minus_import_<cualquier cosa>.csv. Minus ignora todos los demás archivos de la carpeta, así que una carpeta compartida queda intacta.\n\nEl analizador lee el encabezado que declare tu archivo y trata como vacía cada columna ausente, así que no necesitas las 26. Este es un archivo completo y válido. + date es obligatorio, exactamente yyyy-MM-dd HH:mm. Cualquier otro formato descarta la fila.\namount es obligatorio, un decimal simple mayor que cero. Las filas de Minus son gastos.\ncategory es texto libre opcional, se busca por nombre y se crea si es nuevo. En blanco pasa a Sin categoría.\nextra_note es texto libre opcional. Buen lugar para el id de la transacción de origen.\nperiod_id es recomendable, copiado de current_period_id en la fila __META__. En blanco significa 0.\nid debe omitirse. Un valor distinto de cero sobrescribe una transacción existente.\nis_recurrent es 1 o 0. Si es 1, pon frequency en WEEKLY, BIWEEKLY o MONTHLY.\nis_credit es 1 o 0.\n\nLas filas que el analizador rechaza se omiten una por una; el resto del archivo se importa igualmente. + Reglas que respetar + Nombra los archivos entrantes minus_import_*.csv. Cualquier otro se deja intacto, incluidos minus_synced.csv y las rotaciones minus_backup-. Esto es lo que hace seguro apuntar la sincronización a una carpeta que compartes con otras herramientas.\n\nNada de lo que dejes ahí se elimina. Después de leer un archivo se renombra a .imported, o a .rejected si alguna de sus filas no se pudo analizar, conservando su contenido para que veas qué salió mal. Ninguno de los dos nombres se vuelve a procesar.\n\nNunca escribas un id distinto de cero. Las filas con id mayor que cero reemplazan la transacción que tiene ese id; las filas con id en blanco o cero se insertan como nuevas. Como la exportación lleva los ids reales de las filas, volver a enviar una exportación sin modificar sobrescribe en lugar de duplicar. No puedes usar una exportación como plantilla para filas nuevas sin vaciar antes la columna id.\n\nDefine period_id. Los totales se calculan a partir de las fechas, así que una fila con period_id 0 se muestra correctamente en todas partes. El costo, más limitado, es que no se puede atribuir a un período y sobrevive a la limpieza que se ejecuta al eliminar un período archivado.\n\nLa importación no es idempotente. Minus inserta todas las filas que lee sin comprobar si existe una transacción equivalente. El renombrado posterior a la importación evita que el mismo archivo se lea dos veces, pero enviar las mismas filas con otro nombre las contará dos veces, así que elimina duplicados comparando con minus_synced.csv antes de escribir.\n\nSolo gastos. Los montos de cero o menos se rechazan. Los ingresos y los totales del presupuesto viven en la fila __META__, y reescribirlos activa una restauración completa de los ajustes, así que trata este canal como solo de adición para transacciones. + Asignar un feed de Open Finance + Asigna la fecha del proveedor a date, reformateada a yyyy-MM-dd HH:mm. Asigna el monto a amount como valor absoluto, omitiendo las entradas de dinero. Asigna la categoría del proveedor, o tu propia regla, a category, normalizando los nombres o acumularás casi duplicados. Pon el id de transacción del proveedor en extra_note para que siga visible en la app y le dé a tu deduplicación un identificador estable.\n\nUn ciclo cada dos horas se ve así:\n\n1. Descarga minus_synced.csv.\n2. Analízalo, omitiendo las filas __META__ y __ARCHIVED__, y anota current_period_id.\n3. Obtén las transacciones nuevas del proveedor. Para cada una, comprueba si el archivo descargado ya contiene una con el mismo monto dentro de tu ventana de tiempo, o el mismo id de proveedor en extra_note.\n4. Escribe las no coincidentes en un archivo minus_import_ con las cuatro columnas anteriores más period_id.\n5. Envía el archivo y luego lanza el disparador de sincronización.\n\nEl paso 3 es donde debe ocurrir la deduplicación. Minus no lo hará por ti. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index fc5fd576..ccf28047 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -738,4 +738,60 @@ Todo lo que se repite, con lo que te cuesta por período. Ábrelo para ver la próxima fecha de cobro de cada uno o para detener una serie que ya no pagas. Dinero devuelto Todo lo que registraste empezando con + — una nómina, un reembolso, una transferencia recibida. Se suma a este período en lugar de contar como gasto, así que tu presupuesto restante aumenta. + Notificaciones + Experimental + Experimental + Recordatorios de gasto por notificación + Observa las notificaciones de las apps que elijas y, cuando alguna parece un cargo, te recuerda registrarlo en Minus. + Recordatorios de gasto + Minus lee las notificaciones de las apps que elijas, solo en tu dispositivo, y busca un importe en tu moneda. Nada se guarda ni se envía a ningún sitio hasta que tú lo guardes. + Acceso a las notificaciones + Android necesita tu permiso antes de que Minus pueda leer notificaciones. Puedes revocarlo en cualquier momento. + Abrir acceso a notificaciones + Observar notificaciones + Activa el recordatorio. Minus solo mira las apps seleccionadas abajo e ignora cualquier otra notificación. + Apps a observar + Ninguna app coincide con tu búsqueda. + Elige al menos una app, como la de tu banco, para que el recordatorio haga algo. + Buscar apps + %1$d seleccionadas + Recordatorios de gasto + Recordatorios para registrar un cargo que Minus detectó en la notificación de otra app. + Parece que acabas de gastar %1$s. No olvides restarlo. + Restarlo + Guardar así + Deshacer + Guardado en Minus + %1$s guardado sin categoría. + %1$s guardado para tu próximo período. + reembolso,devolución,abonado,abono de,depósito,saldo,cashback,otp,código de verificación,descuento,promoción + Carpeta de sincronización + Elige una carpeta donde guardar una copia en CSV + Sincronizando con %1$s cada 2 horas + Todavía no se ha ejecutado ninguna sincronización + Última sincronización %1$s, %2$s filas importadas + Restaurado desde la carpeta %1$s + La última sincronización %1$s conservó estos archivos; algunas filas se rechazaron: %2$s + La última sincronización falló %1$s: %2$s + Se revocó el acceso a la carpeta, vuelve a elegirla + Guía de sincronización por carpeta CSV + Cómo leer y escribir los datos de Minus desde un script externo + Sincronización por carpeta CSV + Minus puede mantener una copia en CSV de sus datos en una carpeta que elijas, e importar los archivos CSV que dejes en esa misma carpeta. Las herramientas externas obtienen acceso de lectura y escritura sin tocar la base de datos directamente.\n\nLa base de datos privada sigue siendo la fuente de verdad. La carpeta contiene un espejo en CSV, no el archivo de la base de datos. + Configuración + Elige una carpeta arriba. A partir de ese momento Minus sincroniza cada dos horas, y nunca se elimina nada de la carpeta salvo sus propias copias antiguas.\n\nCada ejecución importa primero y exporta después, así que el archivo que leas siempre refleja lo que acabas de enviar.\n\n1. Importa todos los archivos llamados minus_import_*.csv y renombra cada uno a .imported o .rejected una vez leído.\n2. Escribe minus_synced.csv y conserva junto a él las cinco exportaciones anteriores como copias minus_backup- con fecha.\n\nAndroid agrupa el trabajo en segundo plano durante el modo Doze y el mínimo de programación es de 15 minutos, así que dos horas es un objetivo y no una garantía. Usa el disparador manual cuando lo necesites al instante. + Comandos adb + Enviar transacciones nuevas + Sincronizar de inmediato en lugar de esperar + Leer el estado actual + Sustituye la ruta de la carpeta por la que hayas elegido. El comando de arriba ya nombra el paquete con el que se instala esta compilación, así que funciona tanto en una versión estable como en una beta.\n\nEl disparador trae la app al primer plano; no hay variante sin interfaz. No lleva ninguna ruta de archivo ni URI, solo le dice a la app que revise la carpeta a la que ya le diste acceso, por lo que no necesita permiso de almacenamiento.\n\nTambién puedes apuntar la carpeta a un directorio de Syncthing o Dropbox y olvidarte por completo de adb. + Los archivos que escribe Minus + minus_synced.csv lleva el encabezado completo de 26 columnas y se reescribe en cada ejecución. Las cinco exportaciones anteriores se conservan junto a él como archivos minus_backup- marcados con la hora en que fueron reemplazadas, así que una exportación defectuosa nunca te deja sin copia.\n\nEn una exportación aparecen tres tipos de fila, distinguidos por la primera columna:\n\n__META__ son los ajustes del presupuesto y el período actual. Una sola fila, la primera si está presente.\n__ARCHIVED__ es una fila por cada período de presupuesto cerrado.\nUna marca de tiempo es una transacción.\n\nUn lector que solo quiera transacciones debe omitir las dos filas marcadoras. El valor current_period_id de la fila __META__ es el que debes devolver en period_id al escribir transacciones nuevas.\n\nSi reinstalas y apuntas Minus a una carpeta que ya contiene un minus_synced.csv, restaura desde ese archivo en lugar de sobrescribirlo. + El archivo que escribe tu script + Tu archivo debe llamarse minus_import_<cualquier cosa>.csv. Minus ignora todos los demás archivos de la carpeta, así que una carpeta compartida queda intacta.\n\nEl analizador lee el encabezado que declare tu archivo y trata como vacía cada columna ausente, así que no necesitas las 26. Este es un archivo completo y válido. + date es obligatorio, exactamente yyyy-MM-dd HH:mm. Cualquier otro formato descarta la fila.\namount es obligatorio, un decimal simple mayor que cero. Las filas de Minus son gastos.\ncategory es texto libre opcional, se busca por nombre y se crea si es nuevo. En blanco pasa a Sin categoría.\nextra_note es texto libre opcional. Buen lugar para el id de la transacción de origen.\nperiod_id es recomendable, copiado de current_period_id en la fila __META__. En blanco significa 0.\nid debe omitirse. Un valor distinto de cero sobrescribe una transacción existente.\nis_recurrent es 1 o 0. Si es 1, pon frequency en WEEKLY, BIWEEKLY o MONTHLY.\nis_credit es 1 o 0.\n\nLas filas que el analizador rechaza se omiten una por una; el resto del archivo se importa igualmente. + Reglas que respetar + Nombra los archivos entrantes minus_import_*.csv. Cualquier otro se deja intacto, incluidos minus_synced.csv y las rotaciones minus_backup-. Esto es lo que hace seguro apuntar la sincronización a una carpeta que compartes con otras herramientas.\n\nNada de lo que dejes ahí se elimina. Después de leer un archivo se renombra a .imported, o a .rejected si alguna de sus filas no se pudo analizar, conservando su contenido para que veas qué salió mal. Ninguno de los dos nombres se vuelve a procesar.\n\nNunca escribas un id distinto de cero. Las filas con id mayor que cero reemplazan la transacción que tiene ese id; las filas con id en blanco o cero se insertan como nuevas. Como la exportación lleva los ids reales de las filas, volver a enviar una exportación sin modificar sobrescribe en lugar de duplicar. No puedes usar una exportación como plantilla para filas nuevas sin vaciar antes la columna id.\n\nDefine period_id. Los totales se calculan a partir de las fechas, así que una fila con period_id 0 se muestra correctamente en todas partes. El coste, más limitado, es que no se puede atribuir a un período y sobrevive a la limpieza que se ejecuta al eliminar un período archivado.\n\nLa importación no es idempotente. Minus inserta todas las filas que lee sin comprobar si existe una transacción equivalente. El renombrado posterior a la importación evita que el mismo archivo se lea dos veces, pero enviar las mismas filas con otro nombre las contará dos veces, así que elimina duplicados comparando con minus_synced.csv antes de escribir.\n\nSolo gastos. Los importes de cero o menos se rechazan. Los ingresos y los totales del presupuesto viven en la fila __META__, y reescribirlos activa una restauración completa de los ajustes, así que trata este canal como solo de adición para transacciones. + Asignar un feed de Open Finance + Asigna la fecha del proveedor a date, reformateada a yyyy-MM-dd HH:mm. Asigna el importe a amount como valor absoluto, omitiendo las entradas de dinero. Asigna la categoría del proveedor, o tu propia regla, a category, normalizando los nombres o acumularás casi duplicados. Pon el id de transacción del proveedor en extra_note para que siga visible en la app y le dé a tu deduplicación un identificador estable.\n\nUn ciclo cada dos horas se ve así:\n\n1. Descarga minus_synced.csv.\n2. Analízalo, omitiendo las filas __META__ y __ARCHIVED__, y anota current_period_id.\n3. Obtén las transacciones nuevas del proveedor. Para cada una, comprueba si el archivo descargado ya contiene una con el mismo importe dentro de tu ventana de tiempo, o el mismo id de proveedor en extra_note.\n4. Escribe las no coincidentes en un archivo minus_import_ con las cuatro columnas anteriores más period_id.\n5. Envía el archivo y luego lanza el disparador de sincronización.\n\nEl paso 3 es donde debe ocurrir la deduplicación. Minus no lo hará por ti. diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 9b61a0f2..59324973 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -63,9 +63,9 @@ دوره به پایان رسید بودجه باقیمانده - What do you want to do with the remaining money? + می‌خواهید با پول باقی‌مانده چه کنید؟ تقسیم مساوی - Add %1$s to the budget for the next few days + افزودن %1$s به بودجه چند روز آینده افزودن به فردا اضافه کردن کل مبلغ %1$s به بودجه فردا مشاهده آمار @@ -75,7 +75,7 @@ ویرایش هزینه لغو ویرایش تنظیم پرداخت دوره‌ای - Make recurrent + دوره‌ای کن انتخاب زمان روز صدور صورت حساب تاریخ پایان @@ -88,7 +88,7 @@ %d expense for the next period %d expenses for the next period - New Budget Period + دوره بودجه جدید نمای سریع از هزینه‌های شما و امکان افزودن موارد جدید کل هزینه‌ها هزینه جدید @@ -127,7 +127,7 @@ مخفی کردن اشتراک‌های خارج از دوره نمایش اشتراک‌های خارج از دوره مخفی کردن هزینه‌های دوره‌های قبل - Show past period expenses + نمایش هزینه‌های دوره گذشته حذف تراکنش با خطا مواجه شد ذخیره تراکنش با خطا مواجه شد حذف پرداخت دوره‌ای با خطا مواجه شد @@ -152,7 +152,7 @@ کل بودجه کمترین هزینه بیشترین هزینه - Average per day spent + میانگین خرج روزانه روزهای باقیمانده @@ -168,7 +168,7 @@ پیگیری هزینه‌ها، کل بودجه، و روزهای باقیمانده — با امکان افزودن سریع هزینه جدید مشاهده سریع بودجه خرج‌شده و باقیمانده شمارش معکوس روزهای باقیمانده در دوره بودجه - Full budget overview with top category and days left + نمای کامل بودجه با دسته‌بندی برتر و روزهای باقی‌مانده تقویم نقشه حرارتی برای مشاهده توزیع هزینه‌ها تقویم نقشه حرارتی ماه جاری میانبر سریع برای افزودن یک هزینه جدید @@ -184,7 +184,7 @@ روز باقیمانده نمای کلی بودجه - Spent per day + خرج هر روز روزانه هفتگی دوهفته‌ای @@ -222,8 +222,8 @@ با گذشت زمان یاد بگیرید چقدر می‌توانید پس‌انداز کنید و چقدر خرج کنید. ادامه دوره‌ای - Censor mode is active - All your financial values are currently blurred. + حالت پنهان‌سازی فعال است + همه مقادیر مالی شما در حال حاضر تار شده‌اند. گرفتم حالت حریم خصوصی فعال شد بالای گوشی (محل قرارگیری دوربین) را برای حدود یک ثانیه با دست بپوشانید تا این حالت خاموش شود. @@ -388,8 +388,8 @@ عبور از سقف بودجه بدون بودجه بودجه خود را تنظیم کنید - %1$s will be added - %1$s will be subtracted + %1$s اضافه خواهد شد + %1$s کم خواهد شد روزانه هفتگی دوهفته‌ای @@ -436,7 +436,7 @@ ماهانه دوره به پایان رسید - What do you want to do with the remaining money? + می‌خواهید با پول باقی‌مانده چه کنید؟ تقسیم مساوی پخش کردن مبلغ %1$s به‌طور مساوی بین تمام روزهای بودجه بعدی شما افزودن به امروز @@ -459,7 +459,7 @@ هزینه‌های یک‌باره اگر %1$d%% را در هر دوره برای حدود ۶ ماه پس‌انداز کنید، می‌توانید به این مبلغ برسید: برای رسیدن به %1$s در %2$d ماه، در هر دوره %3$s پس‌انداز کنید. - Showing the default %1$d%% example. Set a savings goal in Settings to target a specific amount. + نمونه پیش‌فرض %1$d%% نشان داده می‌شود. برای هدف‌گذاری مبلغی مشخص، در تنظیمات یک هدف پس‌انداز تعیین کنید. تنظیمات پس‌انداز تقسیم‌بندی ۵۰/۳۰/۲۰ را شخصی‌سازی کنید و در صورت تمایل یک هدف پس‌انداز تعیین کنید @@ -514,7 +514,7 @@ بدون تاریخ بدون نام - Unnamed income + درآمد بی‌نام دلیل خرید دلیل تاریخ @@ -525,7 +525,7 @@ مبلغ کل ثبت همه به‌عنوان پرداخت‌شده - Marked all as paid + همه پرداخت‌شده علامت خوردند ثبت به‌عنوان پرداخت‌شده پرداخت شد @@ -556,7 +556,7 @@ %1$s — صدور صورت‌حساب تا %2$d روز دیگر %1$s — صدور صورت‌حساب امروز EXPENSE - RECURRING EXPENSE + هزینه دوره‌ای دوره‌های گذشته برای دیدن جزئیات روی یک دوره ضربه بزنید. برای حذف، انگشت خود را روی آن نگه دارید. @@ -601,4 +601,207 @@ کل K M + اعلان‌ها + آزمایشی + آزمایشی + یادآوری هزینه از اعلان‌ها + اعلان‌های برنامه‌هایی که انتخاب می‌کنید را بررسی می‌کند و هرگاه اعلانی شبیه یک برداشت باشد، یادآوری می‌کند آن را در Minus ثبت کنید. + یادآوری هزینه‌ها + Minus فقط اعلان‌های برنامه‌های انتخابی شما را و تنها روی دستگاه خودتان می‌خواند و در آن‌ها مبلغی به واحد پول شما را جست‌وجو می‌کند. تا زمانی که خودتان ذخیره نکنید، چیزی ذخیره یا به جایی ارسال نمی‌شود. + دسترسی به اعلان‌ها + برای اینکه Minus بتواند اعلان‌ها را بخواند، اندروید به اجازه شما نیاز دارد. هر زمان بخواهید می‌توانید آن را لغو کنید. + باز کردن دسترسی اعلان‌ها + بررسی اعلان‌ها + یادآوری را روشن می‌کند. Minus فقط به برنامه‌های انتخاب‌شده زیر نگاه می‌کند و هر اعلان دیگری را نادیده می‌گیرد. + برنامه‌های تحت بررسی + هیچ برنامه‌ای با جست‌وجوی شما مطابقت ندارد. + برای اینکه یادآوری کار کند، حداقل یک برنامه مثل برنامه بانک خود را انتخاب کنید. + جست‌وجوی برنامه‌ها + %1$d انتخاب‌شده + یادآوری هزینه‌ها + یادآوری برای ثبت برداشتی که Minus در اعلان برنامه دیگری پیدا کرده است. + به نظر می‌رسد همین حالا %1$s خرج کرده‌اید. فراموش نکنید آن را کم کنید. + کم کن + همین‌طور ذخیره کن + واگرد + در Minus ذخیره شد + %1$s بدون دسته‌بندی ذخیره شد. + %1$s برای دوره بعدی شما ذخیره شد. + بازگشت وجه,عودت,واریز,شارژ,موجودی,کش‌بک,otp,کد تأیید,تخفیف,پیشنهاد ویژه + نگاه کلی به دوره + آنچه در دوره جاری خرج کرده‌اید و آنچه باقی مانده، از جمله هر بدهی باقی‌مانده روی کارت اعتباری. + ویرایش یا حذف یک هزینه + روی یک هزینه بزنید تا باز شود و جزئیات کامل را ببینید. برای ویرایش مبلغ، تاریخ، دسته‌بندی یا یادداشت آن را به راست بکشید؛ برای حذف به چپ بکشید، با چند ثانیه فرصت برای واگرد. + پرداخت‌های پیش‌رو + برداشت‌های دوره‌ای که در این دوره موعدشان می‌رسد، پیش از پرداخت اینجا قرار می‌گیرند. وقتی انجام شد آن را پرداخت‌شده علامت بزنید، یا تنها همان نوبت را حذف کنید تا این بار رد شود بدون اینکه کل سری لغو شود. + دوره گذشته + هزینه‌های دوره پیش از این، که از سر راه کنار گذاشته شده‌اند. این بخش را می‌توانید در تنظیمات، زیر قابلیت‌های اضافی، دوباره خاموش کنید. + برداشت‌های دوره‌ای + هر چیزی که تکرار می‌شود، همراه با هزینه‌اش در هر دوره. بازش کنید تا موعد بعدی هر کدام را ببینید، یا سری‌ای را که دیگر پرداخت نمی‌کنید متوقف کنید. + پولی که برگشته + هر چیزی که با علامت + ثبت کرده‌اید — حقوق، بازگشت وجه، یک واریز. به جای آنکه خرج شمرده شود به این دوره اضافه می‌شود، پس بودجه باقی‌مانده‌تان بالا می‌رود. + برای خروج بزنید + در حال بارگذاری… + تراکنش بازگردانی نشد + هزینه برای دوره بعدی در صف قرار گرفت + دسته‌بندی جدید + دسته‌بندی جدید %1$s ذخیره شد + مرتب‌سازی الفبایی + یادداشت اضافی + یادداشت + برای فردا %1$s + برای هفته بعد %1$s + برای دو هفته بعد %1$s + برای ماه بعد %1$s + چگونه محاسبه می‌شود + نمایش نحوه محاسبه مبلغ + مانده دوره قبل به بودجه اضافه می‌شود + مانده دوره قبل در روز اول می‌ماند + درآمد و کسورات این دوره + تقسیم‌شده بر همه روزهای دوره + بودجه %1$s + باقی‌مانده از کل بودجه + پخش‌شده روی آنچه از دوره مانده + آنچه مانده + کنار گذاشته‌شده برای برداشت‌های بعدی این دوره + منتقل‌شده از روزهای قبل + مانده‌هایی که انتخاب کردید پخش شوند + بودجه باقی‌مانده + منتقل‌شده + پرداخت‌های دوره‌ای برداشت‌شده + پخش‌شده روی روزهای باقی‌مانده + آنچه خرج کردید + مجموع + بیش از آنچه این بودجه اجازه می‌دهد خرج کرده‌اید. + برداشت‌های پیش‌رو از این بودجه بزرگ‌ترند. + بدهی منتقل‌شده از روزهای قبل. + برای تفکیک کامل بزنید + نکته: در صفحه اصلی نوار بودجه را نگه دارید تا هر زمان این را ببینید + نکته: می‌توانید روی مبلغ محاسبه‌شده در برگه دوره هم بزنید + پوسته AMOLED + استفاده از پس‌زمینه و سطوح مشکی خالص در حالت تاریک + یادداشت اضافی برای هر تراکنش + برای دیدن جزئیات بیشتر بزنید + یک فیلد اختیاری «یادداشت اضافی» کنار دسته‌بندی هنگام افزودن یا ویرایش تراکنش اضافه می‌کند. از آن برای یادآوری جزئیاتی استفاده کنید که دسته‌بندی به‌تنهایی نشان نمی‌دهد — مثلاً اینکه پول «رفت‌وآمد» دقیقاً کجا خرج شده. + فعال‌سازی یادداشت اضافی + کنار گذاشتن برداشت‌های پیش‌رو + همه برداشت‌های دوره‌ای که بعداً در دوره جاری موعدشان می‌رسد را از روز اول کنار می‌گذارد، به جای آنکه هر کدام را در روز موعد کم کند. بودجه باقی‌مانده و مبلغ روزانه فقط آنچه واقعاً قابل خرج است را نشان می‌دهند. + کنار گذاشتن برداشت‌ها از ابتدا + قابلیت‌های اضافی + قابلیت‌های اختیاری و آزمایشی که می‌توانید روشن کنید + هر قابلیت اینجا اختیاری و به‌طور پیش‌فرض خاموش است؛ برخی آزمایشی‌اند و ممکن است در به‌روزرسانی بعدی تغییر کنند. روی یکی بزنید تا روشن شود و دقیقاً ببینید چه چیزی را تغییر می‌دهد. + ذخیره دسته‌بندی به‌تنهایی + یک برچسب «دسته‌بندی جدید» به صفحه بی‌کار اضافه می‌کند تا بتوانید دسته‌بندی را به‌تنهایی و بدون مبلغ یا تراکنش بسازید و ذخیره کنید. + پیشنهاد دسته‌بندی با یک ضربه + حالت فهرست دسته‌بندی + بارگذاری دوره‌های گذشته + دسته‌بندی‌ها + تراکنش‌ها + بودجه و پرداخت‌ها + آزمایشی + روشن + خاموش + پوشه همگام‌سازی + پوشه‌ای برای نگه‌داشتن یک نسخه CSV انتخاب کنید + هر ۲ ساعت با %1$s همگام می‌شود + هنوز هیچ همگام‌سازی اجرا نشده است + آخرین همگام‌سازی %1$s، %2$s ردیف واردشده + از پوشه %1$s بازگردانی شد + آخرین همگام‌سازی %1$s این فایل‌ها را نگه داشت، برخی ردیف‌ها رد شدند: %2$s + آخرین همگام‌سازی شکست خورد %1$s: %2$s + دسترسی به پوشه لغو شد، پوشه را دوباره انتخاب کنید + راهنمای همگام‌سازی پوشه CSV + چگونه داده‌های Minus را از یک اسکریپت بیرونی بخوانید و بنویسید + همگام‌سازی پوشه CSV + Minus می‌تواند یک نسخه CSV از داده‌هایش را در پوشه‌ای که شما انتخاب می‌کنید نگه دارد و فایل‌های CSV را که در همان پوشه می‌گذارید وارد کند. ابزارهای بیرونی بدون دست زدن مستقیم به پایگاه داده، دسترسی خواندن و نوشتن می‌گیرند.\n\nپایگاه داده خصوصی همچنان منبع حقیقت است. پوشه یک آینه CSV را نگه می‌دارد، نه فایل پایگاه داده را. + راه‌اندازی + در بالا یک پوشه انتخاب کنید. از آن پس Minus هر دو ساعت همگام می‌شود و هیچ چیزی در پوشه هرگز حذف نمی‌شود مگر پشتیبان‌های قدیمی خودش.\n\nهر اجرا نخست وارد و سپس صادر می‌کند، پس فایلی که می‌خوانید همیشه همان چیزی را نشان می‌دهد که تازه فرستاده‌اید.\n\n۱. هر فایل با نام minus_import_*.csv را وارد می‌کند و پس از خواندن نامش را به .imported یا .rejected تغییر می‌دهد.\n۲. فایل minus_synced.csv را می‌نویسد و پنج صادرات پیشین را در کنارش به‌صورت نسخه‌های تاریخ‌دار minus_backup- نگه می‌دارد.\n\nاندروید کارهای پس‌زمینه را در حالت Doze دسته‌بندی می‌کند و کمینه زمان‌بندی ۱۵ دقیقه است، پس دو ساعت یک هدف است نه یک تضمین. وقتی همین حالا لازم دارید از اجرای دستی استفاده کنید. + دستورهای adb + فرستادن تراکنش‌های جدید + همگام‌سازی فوری به جای انتظار + خواندن وضعیت کنونی + مسیر پوشه را با مسیری که انتخاب کرده‌اید جایگزین کنید. دستور بالا نام بسته‌ای که این نسخه با آن نصب می‌شود را از قبل دارد، پس هم روی نسخه نهایی و هم روی بتا کار می‌کند.\n\nاجرا، برنامه را به پیش‌زمینه می‌آورد؛ گونه بدون رابط وجود ندارد. هیچ مسیر فایل یا URI حمل نمی‌کند و فقط به برنامه می‌گوید پوشه‌ای را که از قبل دسترسی داده‌اید بررسی کند، پس به اجازه حافظه نیازی ندارد.\n\nمی‌توانید پوشه را به یک دایرکتوری Syncthing یا Dropbox هم نشانه بگیرید و کلاً adb را کنار بگذارید. + فایل‌هایی که Minus می‌نویسد + فایل minus_synced.csv سرستون کامل ۲۶ ستونی را دارد و در هر اجرا بازنویسی می‌شود. پنج صادرات پیشین در کنارش به‌صورت فایل‌های minus_backup- با زمان جایگزینی نگه داشته می‌شوند، پس یک صادرات خراب هرگز شما را بی‌نسخه نمی‌گذارد.\n\nدر یک صادرات سه نوع ردیف دیده می‌شود که از ستون اول تشخیص داده می‌شوند:\n\n__META__ تنظیمات بودجه و دوره جاری است. یک ردیف، در صورت وجود نخستین ردیف.\n__ARCHIVED__ برای هر دوره بودجه بسته‌شده یک ردیف است.\nیک برچسب زمانی یعنی یک تراکنش.\n\nخواننده‌ای که فقط تراکنش می‌خواهد باید دو ردیف نشانه را رد کند. مقدار current_period_id در ردیف __META__ همان است که هنگام نوشتن تراکنش‌های جدید در period_id بازمی‌گردانید.\n\nاگر برنامه را دوباره نصب کنید و Minus را به پوشه‌ای نشانه بگیرید که از قبل minus_synced.csv دارد، به جای بازنویسی از همان فایل بازگردانی می‌کند. + فایلی که اسکریپت شما می‌نویسد + نام فایل شما باید minus_import_<هر چیزی>.csv باشد. Minus هر فایل دیگری در پوشه را نادیده می‌گیرد، پس یک پوشه مشترک دست‌نخورده می‌ماند.\n\nتحلیلگر هر سرستونی را که فایل شما اعلام کند می‌خواند و هر ستون نبوده را خالی در نظر می‌گیرد، پس به همه ۲۶ ستون نیازی ندارید. این یک فایل کامل و معتبر است. + مقدار date الزامی است، دقیقاً yyyy-MM-dd HH:mm. هر شکل دیگری ردیف را دور می‌اندازد.\nمقدار amount الزامی است، یک عدد اعشاری ساده بزرگ‌تر از صفر. ردیف‌های Minus هزینه هستند.\nمقدار category متن آزاد اختیاری است، با نام تطبیق می‌شود و اگر جدید باشد ساخته می‌شود. خالی بودن به دسته‌بندی‌نشده می‌رسد.\nمقدار extra_note متن آزاد اختیاری است. جای خوبی برای شناسه تراکنش سیستم مبدأ.\nمقدار period_id توصیه می‌شود و از current_period_id در ردیف __META__ کپی می‌شود. خالی یعنی ۰.\nمقدار id را باید نگذارید. مقدار غیرصفر یک تراکنش موجود را بازنویسی می‌کند.\nمقدار is_recurrent یک یا صفر است. اگر یک بود، frequency را روی WEEKLY، BIWEEKLY یا MONTHLY بگذارید.\nمقدار is_credit یک یا صفر است.\n\nردیف‌هایی که تحلیلگر رد می‌کند یکی‌یکی کنار گذاشته می‌شوند؛ بقیه فایل همچنان وارد می‌شود. + قاعده‌هایی که باید رعایت شوند + فایل‌های ورودی را minus_import_*.csv نام بگذارید. هر چیز دیگری دست‌نخورده می‌ماند، از جمله minus_synced.csv و چرخش‌های minus_backup-. همین است که نشانه گرفتن همگام‌سازی به پوشه‌ای که با ابزارهای دیگر مشترک است را بی‌خطر می‌کند.\n\nهیچ چیزی که می‌گذارید حذف نمی‌شود. پس از خواندن، نام فایل به .imported تغییر می‌کند، یا به .rejected اگر هر یک از ردیف‌هایش تحلیل نشد، و محتوایش می‌ماند تا ببینید چه چیزی اشتباه بوده. هیچ‌یک از این دو نام دوباره خوانده نمی‌شود.\n\nهرگز id غیرصفر نگذارید. ردیف‌هایی با id بزرگ‌تر از صفر، تراکنشی را که آن id را دارد جایگزین می‌کنند؛ ردیف‌هایی با id خالی یا صفر به‌عنوان جدید درج می‌شوند. چون صادرات شناسه‌های واقعی ردیف‌ها را دارد، فرستادن دوباره یک صادرات تغییرنیافته بازنویسی می‌کند نه تکرار. نمی‌توانید از یک صادرات به‌عنوان قالب ردیف‌های جدید استفاده کنید مگر اینکه نخست ستون id را خالی کنید.\n\nمقدار period_id را بگذارید. مجموع‌ها از تاریخ‌ها محاسبه می‌شوند، پس ردیفی با period_id صفر همه‌جا درست نشان داده می‌شود. هزینه محدودترش این است که به هیچ دوره‌ای نسبت داده نمی‌شود و از پاک‌سازی‌ای که هنگام حذف یک دوره بایگانی‌شده اجرا می‌شود جان سالم به در می‌برد.\n\nورود داده خودتوان نیست. Minus هر ردیفی را که می‌خواند درج می‌کند، بدون بررسی وجود تراکنش هم‌ارز. تغییر نام پس از ورود جلوی دوباره خواندن همان فایل را می‌گیرد، اما فرستادن همان ردیف‌ها با نامی تازه آن‌ها را دو بار می‌شمارد، پس پیش از نوشتن، تکراری‌ها را در برابر minus_synced.csv حذف کنید.\n\nفقط هزینه‌ها. مبالغ صفر یا کمتر رد می‌شوند. درآمد و مجموع‌های بودجه در ردیف __META__ هستند و بازنویسی آن‌ها یک بازگردانی کامل تنظیمات را راه می‌اندازد، پس این مسیر را برای تراکنش‌ها فقط افزودنی در نظر بگیرید. + نگاشت یک خوراک Open Finance + تاریخ ارائه‌دهنده را به date نگاشت کنید و به yyyy-MM-dd HH:mm درآورید. مبلغ را به‌صورت قدر مطلق به amount نگاشت کنید و ورودی‌های پول را رد کنید. دسته‌بندی ارائه‌دهنده، یا قاعده خودتان، را به category نگاشت کنید و نام‌ها را یکدست کنید وگرنه نزدیک‌تکراری‌ها انبار می‌شوند. شناسه تراکنش ارائه‌دهنده را در extra_note بگذارید تا در برنامه دیده شود و به حذف تکراری‌های شما دستگیره‌ای پایدار بدهد.\n\nیک حلقه دوساعته این‌گونه است:\n\n۱. فایل minus_synced.csv را بگیرید.\n۲. آن را تحلیل کنید، ردیف‌های __META__ و __ARCHIVED__ را رد کنید و current_period_id را یادداشت کنید.\n۳. تراکنش‌های جدید ارائه‌دهنده را بگیرید. برای هر کدام بررسی کنید آیا فایل گرفته‌شده از قبل یکی با همان مبلغ در بازه زمانی شما دارد، یا همان شناسه ارائه‌دهنده در extra_note.\n۴. موارد بی‌تطبیق را در یک فایل minus_import_ با چهار ستون بالا به‌علاوه period_id بنویسید.\n۵. فایل را بفرستید، سپس همگام‌سازی را اجرا کنید.\n\nگام ۳ جایی است که حذف تکراری‌ها باید رخ دهد. Minus این کار را برایتان نمی‌کند. + اشتراک‌ها + دوره‌ای + تعهد ماهانه + /ماه + %1$d فعال — بعدی %2$s + %1$d فعال + موعد نزدیک + پیش‌رو + موعد امروز + علامت‌زدن به‌عنوان پرداخت‌شده + رد کردن در این دوره + هنوز اشتراکی نیست + یک هزینه را دوره‌ای علامت بزنید و اینجا با موعد و زمان باقی‌مانده‌اش نمایان می‌شود. + این پرداخت تأیید نشد + این پرداخت رد نشد + پرداخت‌شده + رد‌شده + %1$s از بودجه شما در این دوره + این تغییر ذخیره نشد + این اشتراک حذف نشد + بر پایه دوره صورت‌حساب + کل دوره + هفتگی + دسته‌بندی + هفته %d + توزیع دسته‌بندی + %s مجموع + پرداخت‌شده + رد کن + در %1$d روز (%2$s) + در %1$d هفته (%2$s) + پول اضافی در انتظار + از یک دوره پیشین + برای مدیریت بزنید + مدت + واحد پول پایه + رفتار بودجه + انتخاب کنید با پول باقی‌مانده چه شود و مبلغ روزانه‌تان چگونه محاسبه گردد. + مانده پایان دوره + از من بپرس + پخش همه + یک‌جا در روز ۱ + وقتی دوره تمام شود، شما انتخاب می‌کنید با مانده چه شود. + مانده به‌طور یکنواخت بین همه روزهای دوره بعد تقسیم می‌شود. + همه مانده به روز اول دوره بعد اضافه می‌شود. + ذخیره برای بعد + آنچه روزانه خرج نمی‌کنید به‌عنوان بودجه به روز بعد اضافه می‌شود و روی هم جمع می‌گردد. + هر روز به انتخاب شما + هر روز شما تصمیم می‌گیرید با بودجه استفاده‌نشده دیروز چه شود: روی روزهای باقی‌مانده پخش شود یا به امروز منتقل گردد. خرج بیش از حد همیشه روی روزهای باقی‌مانده پخش می‌شود، پس مبلغ روزانه پایین می‌آید. + پخش روی روزهای باقی‌مانده + همه را به امروز اضافه کن + همه بودجه باقی‌مانده استفاده‌نشده را به همه روزهای مانده تخصیص می‌دهد. + همه بودجه استفاده‌نشده را فقط به امروز اضافه می‌کند. + %1$s برای روزهای پیش‌رو + %1$s برای فردا + مانده امروز — سپس %1$s در روز + مانده امروز + پرداخت شما برای «%1$s» امروز موعد دارد + هزینه هفتگی شما به مبلغ %1$s برداشت خواهد شد. + هزینه دوهفتگی شما به مبلغ %1$s برداشت خواهد شد. + هزینه ماهانه شما به مبلغ %1$s برداشت خواهد شد. + باز کردن برای ویرایش + باز کردن دوره گذشته + برای تغییر هزینه‌های پیشین با اثر انگشت خود را تأیید کنید + ویرایش دوره گذشته با موفقیت باز شد + هزینه دوره گذشته تغییر کند؟ + مطمئنید می‌خواهید این هزینه را از یک دوره گذشته ویرایش یا حذف کنید؟ تأیید زیست‌سنجی لازم خواهد بود. + دوره گذشته حذف شود؟ + این کار دوره %1$s — %2$s و هزینه‌هایش را برای همیشه حذف می‌کند. اشتراک‌ها نگه داشته می‌شوند. تأیید زیست‌سنجی لازم خواهد بود. + بله + هنوز دوره بودجه‌ای نیست + یک دوره بودجه بسازید تا خرج‌ها، دسته‌بندی‌ها و پس‌انداز شما اینجا نمایان شود. + نوار بودجه + باقی‌مانده برای حالت نمایشی که انتخاب کرده‌اید — روزانه، هفتگی، دوهفتگی یا ماهانه — در یک نوار باریک diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 4c097ec3..b0c2dbf7 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -738,4 +738,60 @@ Tout ce qui se répète, avec ce que cela vous coûte par période. Ouvrez pour voir la prochaine échéance de chacun, ou pour arrêter une série que vous ne payez plus. Argent recrédité Tout ce que vous avez saisi en commençant par + — un salaire, un remboursement, un virement reçu. C\'est ajouté à cette période au lieu de compter comme une dépense, votre budget restant augmente donc. + Notifications + Expérimental + Expérimental + Rappels de dépense par notification + Surveille les notifications des applications que vous choisissez et, lorsque l\'une ressemble à un débit, vous rappelle de l\'enregistrer dans Minus. + Rappels de dépense + Minus lit les notifications des seules applications que vous choisissez, uniquement sur votre appareil, et y recherche un montant dans votre devise. Rien n\'est enregistré ni envoyé où que ce soit avant que vous ne l\'enregistriez vous-même. + Accès aux notifications + Android a besoin de votre autorisation pour que Minus puisse lire les notifications. Vous pouvez la révoquer à tout moment. + Ouvrir l\'accès aux notifications + Surveiller les notifications + Active le rappel. Minus ne regarde que les applications sélectionnées ci-dessous et ignore toutes les autres notifications. + Applications à surveiller + Aucune application ne correspond à votre recherche. + Choisissez au moins une application, comme celle de votre banque, pour que le rappel serve à quelque chose. + Rechercher des applications + %1$d sélectionnées + Rappels de dépense + Rappels pour enregistrer un débit que Minus a repéré dans la notification d\'une autre application. + Il semble que vous venez de dépenser %1$s. N\'oubliez pas de le soustraire. + Soustraire + Enregistrer tel quel + Annuler + Enregistré dans Minus + %1$s enregistré sans catégorie. + %1$s enregistré pour votre prochaine période. + remboursement,remboursé,crédité,dépôt,solde,cashback,otp,code de vérification,remise,promo + Dossier de synchronisation + Choisissez un dossier où conserver une copie CSV + Synchronisation vers %1$s toutes les 2 heures + Aucune synchronisation n\'a encore eu lieu + Dernière synchronisation %1$s, %2$s lignes importées + Restauré depuis le dossier %1$s + La dernière synchronisation %1$s a conservé ces fichiers, certaines lignes ont été rejetées : %2$s + La dernière synchronisation a échoué %1$s : %2$s + L\'accès au dossier a été révoqué, choisissez-le à nouveau + Guide de synchronisation par dossier CSV + Comment lire et écrire les données de Minus depuis un script externe + Synchronisation par dossier CSV + Minus peut conserver une copie CSV de ses données dans un dossier que vous choisissez, et importer les fichiers CSV que vous déposez dans ce même dossier. Les outils externes obtiennent un accès en lecture et en écriture sans toucher directement à la base de données.\n\nLa base de données privée reste la source de vérité. Le dossier contient un miroir CSV, pas le fichier de la base de données. + Configuration + Choisissez un dossier ci-dessus. Minus se synchronise ensuite toutes les deux heures, et rien n\'est jamais supprimé du dossier à part ses propres anciennes sauvegardes.\n\nChaque exécution importe d\'abord, puis exporte, de sorte que le fichier que vous lisez reflète toujours ce que vous venez d\'envoyer.\n\n1. Importe tous les fichiers nommés minus_import_*.csv, en renommant chacun en .imported ou .rejected une fois lu.\n2. Écrit minus_synced.csv, en gardant à côté les cinq exportations précédentes sous forme de copies minus_backup- datées.\n\nAndroid regroupe le travail en arrière-plan pendant le mode Doze et le plancher de planification est de 15 minutes : deux heures est donc un objectif et non une garantie. Utilisez le déclencheur manuel quand vous en avez besoin tout de suite. + Commandes adb + Envoyer de nouvelles transactions + Synchroniser immédiatement au lieu d\'attendre + Lire l\'état actuel + Remplacez le chemin du dossier par celui que vous avez choisi. La commande ci-dessus nomme déjà le paquet sous lequel cette version s\'installe, elle fonctionne donc que vous soyez sur une version finale ou sur une bêta.\n\nLe déclencheur met l\'application au premier plan ; il n\'existe pas de variante sans interface. Il ne transporte aucun chemin de fichier ni URI, il demande seulement à l\'application de parcourir le dossier auquel vous avez déjà donné accès, et n\'a donc besoin d\'aucune autorisation de stockage.\n\nVous pouvez aussi faire pointer le dossier vers un répertoire Syncthing ou Dropbox et vous passer complètement d\'adb. + Les fichiers que Minus écrit + minus_synced.csv porte l\'en-tête complet de 26 colonnes et est réécrit à chaque exécution. Les cinq exportations précédentes sont conservées à côté sous forme de fichiers minus_backup- horodatés au moment de leur remplacement, pour qu\'une mauvaise exportation ne vous laisse jamais sans copie.\n\nTrois types de ligne apparaissent dans une exportation, distingués par la première colonne :\n\n__META__ correspond aux paramètres du budget et à la période en cours. Une seule ligne, en premier si elle est présente.\n__ARCHIVED__ est une ligne par période de budget clôturée.\nUn horodatage est une transaction.\n\nUn lecteur qui ne veut que les transactions doit ignorer les deux lignes de marquage. La valeur current_period_id de la ligne __META__ est celle que vous renvoyez dans period_id lors de l\'écriture de nouvelles transactions.\n\nSi vous réinstallez et faites pointer Minus vers un dossier qui contient déjà un minus_synced.csv, il restaure depuis ce fichier au lieu de l\'écraser. + Le fichier que votre script écrit + Votre fichier doit être nommé minus_import_<n\'importe quoi>.csv. Minus ignore tous les autres fichiers du dossier, un dossier partagé reste donc intact.\n\nL\'analyseur lit l\'en-tête que votre fichier déclare et traite toute colonne manquante comme vide : vous n\'avez donc pas besoin des 26. Voici un fichier complet et valide. + date est obligatoire, exactement yyyy-MM-dd HH:mm. Toute autre forme écarte la ligne.\namount est obligatoire, un décimal simple supérieur à zéro. Les lignes de Minus sont des dépenses.\ncategory est un texte libre optionnel, associé par nom et créé s\'il est nouveau. Vide, il retombe sur Non catégorisé.\nextra_note est un texte libre optionnel. Bon endroit pour l\'identifiant de la transaction d\'origine.\nperiod_id est recommandé, copié depuis current_period_id dans la ligne __META__. Vide signifie 0.\nid doit être omis. Une valeur non nulle écrase une transaction existante.\nis_recurrent vaut 1 ou 0. Si 1, réglez frequency sur WEEKLY, BIWEEKLY ou MONTHLY.\nis_credit vaut 1 ou 0.\n\nLes lignes que l\'analyseur rejette sont ignorées une par une ; le reste du fichier est quand même importé. + Règles à respecter + Nommez les fichiers entrants minus_import_*.csv. Tout le reste est laissé tranquille, y compris minus_synced.csv et les rotations minus_backup-. C\'est ce qui rend sûr de faire pointer la synchronisation vers un dossier que vous partagez avec d\'autres outils.\n\nRien de ce que vous déposez n\'est supprimé. Après lecture, un fichier est renommé en .imported, ou en .rejected si l\'une de ses lignes n\'a pas pu être analysée, en conservant son contenu pour que vous voyiez ce qui a échoué. Aucun des deux noms n\'est repris.\n\nN\'écrivez jamais d\'id non nul. Les lignes dont l\'id est supérieur à zéro remplacent la transaction portant cet id ; les lignes dont l\'id est vide ou nul sont insérées comme nouvelles. Comme l\'exportation porte les véritables id de lignes, renvoyer une exportation non modifiée écrase au lieu de dupliquer. Vous ne pouvez pas utiliser une exportation comme modèle pour de nouvelles lignes sans vider d\'abord la colonne id.\n\nRenseignez period_id. Les totaux sont calculés à partir des dates, donc une ligne avec period_id 0 s\'affiche correctement partout. Le coût, plus limité, est qu\'elle n\'est pas rattachable à une période et survit au nettoyage effectué lorsqu\'une période archivée est supprimée.\n\nL\'import n\'est pas idempotent. Minus insère chaque ligne qu\'il lit sans vérifier s\'il existe une transaction équivalente. Le renommage après import empêche de relire deux fois le même fichier, mais renvoyer les mêmes lignes sous un nouveau nom les comptera deux fois : dédupliquez donc par rapport à minus_synced.csv avant d\'écrire.\n\nDépenses uniquement. Les montants de zéro ou moins sont rejetés. Les revenus et les totaux du budget se trouvent dans la ligne __META__, et les réécrire déclenche une restauration complète des paramètres : traitez donc ce canal comme réservé à l\'ajout de transactions. + Mapper un flux Open Finance + Mappez la date du fournisseur sur date, reformatée en yyyy-MM-dd HH:mm. Mappez le montant sur amount en valeur absolue, en ignorant les entrées d\'argent. Mappez la catégorie du fournisseur, ou votre propre règle, sur category, en normalisant les noms sous peine d\'accumuler des quasi-doublons. Mettez l\'identifiant de transaction du fournisseur dans extra_note pour qu\'il reste visible dans l\'application et donne à votre déduplication une référence stable.\n\nUne boucle toutes les deux heures ressemble à ceci :\n\n1. Récupérez minus_synced.csv.\n2. Analysez-le, en ignorant les lignes __META__ et __ARCHIVED__, et notez current_period_id.\n3. Récupérez les nouvelles transactions du fournisseur. Pour chacune, vérifiez si le fichier récupéré en contient déjà une avec le même montant dans votre fenêtre de temps, ou le même identifiant de fournisseur dans extra_note.\n4. Écrivez celles qui ne correspondent à rien dans un fichier minus_import_ avec les quatre colonnes ci-dessus plus period_id.\n5. Envoyez le fichier, puis lancez le déclencheur de synchronisation.\n\nC\'est à l\'étape 3 que la déduplication doit se faire. Minus ne le fera pas pour vous. diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index bfa8627d..f063728a 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -738,4 +738,60 @@ जो कुछ भी दोहराया जाता है, और हर अवधि में उसकी लागत। हर एक की अगली देय तारीख देखने या जो सीरीज़ अब आप नहीं चुकाते उसे रोकने के लिए इसे खोलें। वापस जुड़ा पैसा वह सब जो आपने + से शुरू करके दर्ज किया — सैलरी, रिफ़ंड, आया हुआ ट्रांसफ़र। यह खर्च में गिनने के बजाय इस अवधि में जुड़ जाता है, इसलिए आपका बचा हुआ बजट बढ़ जाता है। + नोटिफ़िकेशन + एक्सपेरिमेंटल + एक्सपेरिमेंटल + नोटिफ़िकेशन से खर्च रिमाइंडर + आपके चुने हुए ऐप्स के नोटिफ़िकेशन देखता है और जब कोई खर्च जैसा लगे, तो उसे Minus में दर्ज करने की याद दिलाता है। + खर्च रिमाइंडर + Minus सिर्फ़ आपके चुने हुए ऐप्स के नोटिफ़िकेशन पढ़ता है, वह भी केवल आपके डिवाइस पर, और उनमें आपकी करेंसी की रकम खोजता है। जब तक आप खुद सेव नहीं करते, कुछ भी स्टोर या कहीं भेजा नहीं जाता। + नोटिफ़िकेशन ऐक्सेस + Minus के नोटिफ़िकेशन पढ़ने से पहले Android को आपकी अनुमति चाहिए। आप इसे कभी भी हटा सकते हैं। + नोटिफ़िकेशन ऐक्सेस खोलें + नोटिफ़िकेशन देखें + रिमाइंडर चालू करता है। Minus सिर्फ़ नीचे चुने गए ऐप्स देखता है और बाकी हर नोटिफ़िकेशन को नज़रअंदाज़ करता है। + देखने वाले ऐप्स + आपकी खोज से कोई ऐप मेल नहीं खाता। + रिमाइंडर काम करने के लिए कम से कम एक ऐप चुनें, जैसे आपका बैंक ऐप। + ऐप्स खोजें + %1$d चुने गए + खर्च रिमाइंडर + किसी दूसरे ऐप के नोटिफ़िकेशन में Minus को मिले खर्च को दर्ज करने के रिमाइंडर। + लगता है आपने अभी %1$s खर्च किया है। इसे माइनस करना न भूलें। + माइनस करें + ऐसे ही सेव करें + पहले जैसा करें + Minus में सेव हुआ + %1$s बिना कैटेगरी सेव हुआ। + %1$s आपकी अगली अवधि के लिए सेव हुआ। + रिफ़ंड,वापसी,क्रेडिट,जमा,बैलेंस,कैशबैक,otp,वेरिफ़िकेशन कोड,छूट,ऑफ़र + सिंक फ़ोल्डर + CSV कॉपी रखने के लिए एक फ़ोल्डर चुनें + हर 2 घंटे में %1$s पर सिंक हो रहा है + अभी तक कोई सिंक नहीं चला + पिछला सिंक %1$s, %2$s पंक्तियाँ इंपोर्ट हुईं + फ़ोल्डर %1$s से रिस्टोर किया गया + पिछले सिंक %1$s ने ये फ़ाइलें रखीं, कुछ पंक्तियाँ अस्वीकार हुईं: %2$s + पिछला सिंक %1$s पर फ़ेल हुआ: %2$s + फ़ोल्डर का ऐक्सेस हटा दिया गया, फ़ोल्डर फिर से चुनें + CSV फ़ोल्डर सिंक गाइड + बाहरी स्क्रिप्ट से Minus का डेटा कैसे पढ़ें और लिखें + CSV फ़ोल्डर सिंक + Minus आपके चुने हुए फ़ोल्डर में अपने डेटा की CSV कॉपी रख सकता है, और उसी फ़ोल्डर में डाली गई CSV फ़ाइलें इंपोर्ट कर सकता है। बाहरी टूल्स को डेटाबेस को सीधे छुए बिना पढ़ने और लिखने का ऐक्सेस मिल जाता है।\n\nनिजी डेटाबेस ही सत्य का स्रोत रहता है। फ़ोल्डर में CSV मिरर होता है, डेटाबेस फ़ाइल नहीं। + सेटअप + ऊपर एक फ़ोल्डर चुनें। उसके बाद Minus हर दो घंटे में सिंक करता है, और फ़ोल्डर में उसके अपने पुराने बैकअप के अलावा कुछ भी कभी नहीं हटाया जाता।\n\nहर रन पहले इंपोर्ट करता है, फिर एक्सपोर्ट, इसलिए आप जो फ़ाइल पढ़ते हैं वह हमेशा आपके अभी भेजे गए डेटा को दिखाती है।\n\n1. minus_import_*.csv नाम की हर फ़ाइल इंपोर्ट करता है, और पढ़ने के बाद उसका नाम .imported या .rejected कर देता है।\n2. minus_synced.csv लिखता है, और पिछले पाँच एक्सपोर्ट उसके साथ तारीख वाली minus_backup- कॉपियों के रूप में रखता है।\n\nAndroid, Doze के दौरान बैकग्राउंड काम को बैच में करता है और शेड्यूलिंग की न्यूनतम सीमा 15 मिनट है, इसलिए दो घंटे एक लक्ष्य है, गारंटी नहीं। जब तुरंत चाहिए तो मैनुअल ट्रिगर इस्तेमाल करें। + adb कमांड + नए ट्रांज़ैक्शन भेजें + इंतज़ार करने के बजाय तुरंत सिंक करें + मौजूदा स्थिति पढ़ें + फ़ोल्डर पाथ को अपने चुने हुए पाथ से बदलें। ऊपर दी गई कमांड में वह पैकेज पहले से लिखा है जिसके नाम से यह बिल्ड इंस्टॉल होता है, इसलिए यह रिलीज़ और बीटा दोनों पर काम करती है।\n\nट्रिगर ऐप को सामने ले आता है; बिना इंटरफ़ेस वाला कोई विकल्प नहीं है। यह न कोई फ़ाइल पाथ भेजता है न URI, सिर्फ़ ऐप को कहता है कि जिस फ़ोल्डर का ऐक्सेस आपने पहले दिया है उसे स्कैन करे, इसलिए इसे स्टोरेज अनुमति की ज़रूरत नहीं।\n\nआप फ़ोल्डर को Syncthing या Dropbox डायरेक्टरी पर भी सेट कर सकते हैं और adb की ज़रूरत पूरी तरह खत्म कर सकते हैं। + Minus जो फ़ाइलें लिखता है + minus_synced.csv में पूरे 26 कॉलम का हेडर होता है और यह हर रन में फिर से लिखी जाती है। पिछले पाँच एक्सपोर्ट उसके साथ minus_backup- फ़ाइलों के रूप में रहते हैं, जिन पर उन्हें बदले जाने का समय अंकित होता है, ताकि एक खराब एक्सपोर्ट आपको कभी बिना कॉपी न छोड़े।\n\nएक्सपोर्ट में तीन तरह की पंक्तियाँ आती हैं, जो पहले कॉलम से पहचानी जाती हैं:\n\n__META__ बजट सेटिंग्स और मौजूदा अवधि है। एक पंक्ति, मौजूद हो तो सबसे पहले।\n__ARCHIVED__ हर बंद हुई बजट अवधि के लिए एक पंक्ति है।\nटाइमस्टैम्प एक ट्रांज़ैक्शन है।\n\nजिसे सिर्फ़ ट्रांज़ैक्शन चाहिए उसे ये दो मार्कर पंक्तियाँ छोड़नी होंगी। __META__ पंक्ति में current_period_id की वैल्यू ही वह है जो आप नए ट्रांज़ैक्शन लिखते समय period_id में वापस देते हैं।\n\nअगर आप फिर से इंस्टॉल करके Minus को ऐसे फ़ोल्डर पर सेट करते हैं जिसमें पहले से minus_synced.csv है, तो वह उसे ओवरराइट करने के बजाय उसी फ़ाइल से रिस्टोर करता है। + आपकी स्क्रिप्ट जो फ़ाइल लिखती है + आपकी फ़ाइल का नाम minus_import_<कुछ भी>.csv होना चाहिए। Minus फ़ोल्डर की हर दूसरी फ़ाइल को नज़रअंदाज़ करता है, इसलिए साझा फ़ोल्डर अछूता रहता है।\n\nपार्सर आपकी फ़ाइल में घोषित हेडर पढ़ता है और हर गायब कॉलम को खाली मानता है, इसलिए आपको सभी 26 की ज़रूरत नहीं। यह एक पूरी और मान्य फ़ाइल है। + date ज़रूरी है, ठीक yyyy-MM-dd HH:mm। कोई भी दूसरा रूप पंक्ति को हटा देता है।\namount ज़रूरी है, शून्य से बड़ा एक साधारण दशमलव। Minus की पंक्तियाँ खर्च होती हैं।\ncategory वैकल्पिक मुक्त टेक्स्ट है, नाम से मिलाया जाता है और नया हो तो बना दिया जाता है। खाली होने पर बिना कैटेगरी वाले में चला जाता है।\nextra_note वैकल्पिक मुक्त टेक्स्ट है। स्रोत ट्रांज़ैक्शन की id रखने की अच्छी जगह।\nperiod_id सुझाया जाता है, __META__ पंक्ति के current_period_id से कॉपी किया गया। खाली का मतलब 0।\nid को छोड़ देना चाहिए। शून्य से अलग वैल्यू मौजूदा ट्रांज़ैक्शन को ओवरराइट कर देती है।\nis_recurrent 1 या 0 है। 1 हो तो frequency को WEEKLY, BIWEEKLY या MONTHLY पर सेट करें।\nis_credit 1 या 0 है।\n\nपार्सर जिन पंक्तियों को अस्वीकार करता है उन्हें एक-एक कर छोड़ दिया जाता है; बाकी फ़ाइल फिर भी इंपोर्ट होती है। + मानने योग्य नियम + आने वाली फ़ाइलों का नाम minus_import_*.csv रखें। बाकी सब कुछ छुआ नहीं जाता, जिसमें minus_synced.csv और minus_backup- रोटेशन शामिल हैं। इसी वजह से सिंक को उस फ़ोल्डर पर सेट करना सुरक्षित है जिसे आप दूसरे टूल्स के साथ साझा करते हैं।\n\nआप जो भी डालते हैं वह मिटाया नहीं जाता। पढ़ने के बाद फ़ाइल का नाम .imported हो जाता है, या .rejected अगर उसकी कोई पंक्ति पार्स नहीं हो पाई, और उसका कंटेंट बना रहता है ताकि आप देख सकें कि क्या गड़बड़ हुई। इन दोनों नामों को दोबारा नहीं पढ़ा जाता।\n\nशून्य से अलग id कभी न लिखें। शून्य से बड़ी id वाली पंक्तियाँ उस id वाले ट्रांज़ैक्शन की जगह ले लेती हैं; खाली या शून्य id वाली पंक्तियाँ नई के रूप में जुड़ती हैं। चूँकि एक्सपोर्ट में असली पंक्ति id होती हैं, बिना बदलाव वाला एक्सपोर्ट दोबारा भेजने पर वह डुप्लिकेट नहीं बनाता बल्कि ओवरराइट करता है। आप एक्सपोर्ट को नई पंक्तियों के टेम्पलेट के रूप में इस्तेमाल नहीं कर सकते जब तक पहले id कॉलम खाली न कर दें।\n\nperiod_id सेट करें। कुल योग तारीखों से निकाले जाते हैं, इसलिए period_id 0 वाली पंक्ति भी हर जगह सही दिखती है। सीमित नुकसान यह है कि उसे किसी अवधि से नहीं जोड़ा जा सकता और संग्रहित अवधि हटाने पर चलने वाली सफ़ाई में वह बची रहती है।\n\nइंपोर्ट idempotent नहीं है। Minus हर पढ़ी गई पंक्ति को जोड़ देता है, यह जाँचे बिना कि कोई समान ट्रांज़ैक्शन है या नहीं। इंपोर्ट के बाद नाम बदलने से वही फ़ाइल दो बार नहीं पढ़ी जाती, लेकिन वही पंक्तियाँ नए नाम से भेजने पर वे दो बार गिनी जाएँगी, इसलिए लिखने से पहले minus_synced.csv से मिलाकर डुप्लिकेट हटाएँ।\n\nसिर्फ़ खर्च। शून्य या उससे कम रकम अस्वीकार होती है। आय और बजट के कुल योग __META__ पंक्ति में रहते हैं, और उन्हें फिर से लिखने पर पूरी सेटिंग्स रिस्टोर हो जाती हैं, इसलिए इस रास्ते को ट्रांज़ैक्शन जोड़ने के लिए ही मानें। + Open Finance फ़ीड को मैप करना + प्रोवाइडर की तारीख को date पर मैप करें, yyyy-MM-dd HH:mm में बदलकर। रकम को amount पर उसके पूर्ण मान के रूप में मैप करें, आने वाले पैसे को छोड़ दें। प्रोवाइडर की कैटेगरी, या अपना नियम, category पर मैप करें और नाम सामान्य कर लें, वरना लगभग एक जैसे डुप्लिकेट जमा होते जाएँगे। प्रोवाइडर की ट्रांज़ैक्शन id को extra_note में रखें ताकि वह ऐप में दिखती रहे और आपके डुप्लिकेट हटाने को एक स्थिर पकड़ मिले।\n\nदो घंटे का चक्र ऐसा दिखता है:\n\n1. minus_synced.csv डाउनलोड करें।\n2. उसे पार्स करें, __META__ और __ARCHIVED__ पंक्तियाँ छोड़ते हुए, और current_period_id नोट करें।\n3. प्रोवाइडर से नए ट्रांज़ैक्शन लाएँ। हर एक के लिए जाँचें कि डाउनलोड की गई फ़ाइल में आपकी समय सीमा के भीतर उसी रकम वाला, या extra_note में उसी प्रोवाइडर id वाला ट्रांज़ैक्शन पहले से है या नहीं।\n4. जो मेल न खाएँ उन्हें ऊपर दिए चार कॉलम और period_id के साथ minus_import_ फ़ाइल में लिखें।\n5. फ़ाइल भेजें, फिर सिंक ट्रिगर चलाएँ।\n\nचरण 3 ही वह जगह है जहाँ डुप्लिकेट हटाना होता है। Minus यह आपके लिए नहीं करेगा। diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 1b97e18e..022be672 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -623,7 +623,7 @@ Fine periodo di budget Notifiche alla fine del periodo di budget con saldo residuo - Spese ricorrenti Spese + Spese ricorrenti Notifiche per le spese ricorrenti in scadenza Promemoria carta di credito Promemoria prima della chiusura dell\'estratto conto della carta di credito @@ -639,7 +639,7 @@ tra %1$d giorni Spesa in arrivo Per il tuo abbonamento \"%1$s\" relativo a \"%2$s\" verrà addebitato l\'importo di %3$s. Preparati all\'addebito. - You have a subscription of \"%1$s\" that will be charged %2$s. + Hai un abbonamento di \"%1$s\" che verrà addebitato %2$s. Prossimo pagamento carta di credito La scadenza del pagamento è il %1$s. Si consiglia di pagare %2$s per evitare interessi. Totale dovuto @@ -738,4 +738,60 @@ Tutto ciò che si ripete, con quanto ti costa per periodo. Aprilo per vedere la prossima scadenza di ognuno o per interrompere una serie che non paghi più. Denaro riaccreditato Tutto quello che hai registrato iniziando con + — uno stipendio, un rimborso, un bonifico in entrata. Viene aggiunto a questo periodo invece di contare come spesa, quindi il budget rimanente aumenta. + Notifiche + Sperimentale + Sperimentale + Promemoria di spesa dalle notifiche + Osserva le notifiche delle app che scegli e, quando una sembra un addebito, ti ricorda di registrarlo in Minus. + Promemoria di spesa + Minus legge le notifiche delle sole app che scegli, unicamente sul tuo dispositivo, e vi cerca un importo nella tua valuta. Nulla viene salvato o inviato da nessuna parte finché non lo salvi tu. + Accesso alle notifiche + Android richiede la tua autorizzazione prima che Minus possa leggere le notifiche. Puoi revocarla in qualsiasi momento. + Apri accesso alle notifiche + Osserva le notifiche + Attiva il promemoria. Minus guarda solo le app selezionate qui sotto e ignora ogni altra notifica. + App da osservare + Nessuna app corrisponde alla tua ricerca. + Scegli almeno un\'app, come quella della tua banca, perché il promemoria serva a qualcosa. + Cerca app + %1$d selezionate + Promemoria di spesa + Promemoria per registrare un addebito che Minus ha rilevato nella notifica di un\'altra app. + Sembra che tu abbia appena speso %1$s. Non dimenticare di sottrarlo. + Sottrai + Salva così + Annulla + Salvato in Minus + %1$s salvato senza categoria. + %1$s salvato per il tuo prossimo periodo. + rimborso,rimborsato,accredito,accreditato,deposito,saldo,cashback,otp,codice di verifica,sconto,promozione + Cartella di sincronizzazione + Scegli una cartella in cui tenere una copia CSV + Sincronizzazione su %1$s ogni 2 ore + Nessuna sincronizzazione è stata ancora eseguita + Ultima sincronizzazione %1$s, %2$s righe importate + Ripristinato dalla cartella %1$s + L\'ultima sincronizzazione %1$s ha conservato questi file, alcune righe sono state rifiutate: %2$s + L\'ultima sincronizzazione è fallita %1$s: %2$s + L\'accesso alla cartella è stato revocato, scegli di nuovo la cartella + Guida alla sincronizzazione su cartella CSV + Come leggere e scrivere i dati di Minus da uno script esterno + Sincronizzazione su cartella CSV + Minus può tenere una copia CSV dei suoi dati in una cartella che scegli tu e importare i file CSV che lasci in quella stessa cartella. Gli strumenti esterni ottengono accesso in lettura e scrittura senza toccare direttamente il database.\n\nIl database privato resta la fonte di verità. La cartella contiene uno specchio in CSV, non il file del database. + Configurazione + Scegli una cartella qui sopra. Da quel momento Minus sincronizza ogni due ore, e nella cartella non viene mai eliminato nulla tranne i suoi stessi backup vecchi.\n\nOgni esecuzione prima importa e poi esporta, così il file che leggi riflette sempre quello che hai appena inviato.\n\n1. Importa ogni file chiamato minus_import_*.csv, rinominandolo in .imported o .rejected una volta letto.\n2. Scrive minus_synced.csv, conservando accanto le cinque esportazioni precedenti come copie minus_backup- datate.\n\nAndroid raggruppa il lavoro in background durante il Doze e il minimo di pianificazione è di 15 minuti, quindi due ore sono un obiettivo e non una garanzia. Usa l\'attivazione manuale quando ti serve subito. + Comandi adb + Inviare nuove transazioni + Sincronizzare subito invece di aspettare + Leggere lo stato attuale + Sostituisci il percorso della cartella con quello che hai scelto. Il comando qui sopra indica già il pacchetto con cui questa build viene installata, quindi funziona sia su una versione finale sia su una beta.\n\nL\'attivazione porta l\'app in primo piano; non esiste una variante senza interfaccia. Non trasporta alcun percorso di file né URI, dice soltanto all\'app di scansionare la cartella a cui hai già dato accesso, quindi non richiede alcun permesso di archiviazione.\n\nPuoi anche puntare la cartella su una directory di Syncthing o Dropbox e fare a meno di adb del tutto. + I file che Minus scrive + minus_synced.csv contiene l\'intestazione completa di 26 colonne e viene riscritto a ogni esecuzione. Le cinque esportazioni precedenti vengono conservate accanto come file minus_backup- marcati con l\'ora in cui sono state sostituite, così una esportazione sbagliata non ti lascia mai senza copia.\n\nIn una esportazione compaiono tre tipi di riga, distinti dalla prima colonna:\n\n__META__ sono le impostazioni del budget e il periodo corrente. Una sola riga, la prima se presente.\n__ARCHIVED__ è una riga per ogni periodo di budget chiuso.\nUn timestamp è una transazione.\n\nUn lettore che vuole solo le transazioni deve saltare le due righe marcatore. Il valore current_period_id nella riga __META__ è quello che riporti in period_id quando scrivi nuove transazioni.\n\nSe reinstalli e punti Minus su una cartella che contiene già un minus_synced.csv, il ripristino avviene da quel file invece di sovrascriverlo. + Il file che scrive il tuo script + Il tuo file deve chiamarsi minus_import_<qualsiasi cosa>.csv. Minus ignora ogni altro file nella cartella, quindi una cartella condivisa resta intatta.\n\nIl parser legge l\'intestazione che il tuo file dichiara e tratta ogni colonna mancante come vuota, quindi non ti servono tutte le 26. Questo è un file completo e valido. + date è obbligatorio, esattamente yyyy-MM-dd HH:mm. Qualsiasi altra forma scarta la riga.\namount è obbligatorio, un decimale semplice maggiore di zero. Le righe di Minus sono spese.\ncategory è testo libero opzionale, abbinato per nome e creato se nuovo. Se vuoto ricade su Non categorizzato.\nextra_note è testo libero opzionale. Buon posto per l\'id della transazione di origine.\nperiod_id è consigliato, copiato da current_period_id nella riga __META__. Vuoto significa 0.\nid va omesso. Un valore diverso da zero sovrascrive una transazione esistente.\nis_recurrent è 1 o 0. Se è 1, imposta frequency su WEEKLY, BIWEEKLY o MONTHLY.\nis_credit è 1 o 0.\n\nLe righe che il parser rifiuta vengono saltate una per una; il resto del file viene importato comunque. + Regole da rispettare + Chiama i file in ingresso minus_import_*.csv. Tutto il resto viene lasciato in pace, compresi minus_synced.csv e le rotazioni minus_backup-. È questo che rende sicuro puntare la sincronizzazione su una cartella condivisa con altri strumenti.\n\nNiente di ciò che lasci lì viene eliminato. Dopo la lettura un file viene rinominato in .imported, oppure in .rejected se una delle sue righe non è stata analizzata, conservando il contenuto perché tu possa vedere cosa è andato storto. Nessuno dei due nomi viene letto di nuovo.\n\nNon scrivere mai un id diverso da zero. Le righe con id maggiore di zero sostituiscono la transazione che ha quell\'id; le righe con id vuoto o zero vengono inserite come nuove. Poiché l\'esportazione porta gli id reali delle righe, reinviare una esportazione non modificata sovrascrive invece di duplicare. Non puoi usare una esportazione come modello per nuove righe senza prima svuotare la colonna id.\n\nImposta period_id. I totali sono calcolati dalle date, quindi una riga con period_id 0 viene comunque mostrata correttamente in ogni punto. Il costo, più ristretto, è che non è attribuibile a un periodo e sopravvive alla pulizia che viene eseguita quando un periodo archiviato viene eliminato.\n\nL\'importazione non è idempotente. Minus inserisce ogni riga che legge senza controllare se esiste una transazione equivalente. La rinomina dopo l\'importazione impedisce che lo stesso file venga letto due volte, ma inviare le stesse righe con un nome nuovo le conterà due volte, quindi deduplica rispetto a minus_synced.csv prima di scrivere.\n\nSolo spese. Gli importi pari a zero o inferiori vengono rifiutati. Le entrate e i totali del budget stanno nella riga __META__, e riscriverli attiva un ripristino completo delle impostazioni, quindi tratta questo canale come solo in aggiunta per le transazioni. + Mappare un feed Open Finance + Mappa la data del fornitore su date, riformattata in yyyy-MM-dd HH:mm. Mappa l\'importo su amount come valore assoluto, saltando le entrate. Mappa la categoria del fornitore, o una tua regola, su category, normalizzando i nomi o accumulerai quasi-duplicati. Metti l\'id transazione del fornitore in extra_note così resta visibile nell\'app e dà alla tua deduplicazione un riferimento stabile.\n\nUn ciclo ogni due ore è così:\n\n1. Scarica minus_synced.csv.\n2. Analizzalo, saltando le righe __META__ e __ARCHIVED__, e annota current_period_id.\n3. Recupera le nuove transazioni del fornitore. Per ognuna, controlla se il file scaricato ne contiene già una con lo stesso importo nella tua finestra temporale, o lo stesso id fornitore in extra_note.\n4. Scrivi quelle non corrispondenti in un file minus_import_ con le quattro colonne sopra più period_id.\n5. Invia il file, poi lancia l\'attivazione della sincronizzazione.\n\nIl passo 3 è dove deve avvenire la deduplicazione. Minus non lo farà per te. diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 0a3c6b02..71733132 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -738,4 +738,60 @@ くり返されるものと、期間ごとの金額です。開くと、それぞれの次回の支払日を確認したり、支払わなくなったシリーズを停止したりできます。 戻ってきたお金 先頭に + を付けて記録したもの(給料、返金、入金など)です。支出としてではなくこの期間に加算されるため、残りの予算が増えます。 + 通知 + 実験的機能 + 実験的機能 + 通知からの支出リマインダー + 選択したアプリの通知を監視し、支出のような内容を見つけたら Minus に記録するようリマインドします。 + 支出リマインダー + Minus は選択したアプリの通知のみを端末内で読み取り、お使いの通貨の金額を探します。自分で保存するまで、何も保存されず、どこにも送信されません。 + 通知へのアクセス + Minus が通知を読み取るには Android の許可が必要です。許可はいつでも取り消せます。 + 通知アクセスを開く + 通知を監視 + リマインダーを有効にします。Minus は下で選択したアプリのみを見て、それ以外の通知はすべて無視します。 + 監視するアプリ + 検索に一致するアプリがありません。 + リマインダーを機能させるには、銀行アプリなど少なくとも 1 つのアプリを選択してください。 + アプリを検索 + %1$d 件選択中 + 支出リマインダー + 他のアプリの通知で Minus が見つけた支出を記録するためのリマインダーです。 + %1$s を使ったようです。Minus で引くのを忘れないでください。 + 記録する + そのまま保存 + 元に戻す + Minus に保存しました + %1$s をカテゴリなしで保存しました。 + %1$s を次の期間に保存しました。 + 返金,払い戻し,入金,振込入金,残高,キャッシュバック,otp,認証コード,割引,キャンペーン + 同期フォルダ + CSV のコピーを保存するフォルダを選択 + 2 時間ごとに %1$s へ同期中 + 同期はまだ実行されていません + 前回の同期 %1$s、%2$s 行をインポート + フォルダ %1$s から復元しました + 前回の同期 %1$s はこれらのファイルを保持しました。一部の行は拒否されました: %2$s + 前回の同期が失敗しました %1$s: %2$s + フォルダへのアクセスが取り消されました。フォルダを再選択してください + CSV フォルダ同期ガイド + 外部スクリプトから Minus のデータを読み書きする方法 + CSV フォルダ同期 + Minus は選択したフォルダにデータの CSV コピーを保持し、同じフォルダに置いた CSV ファイルをインポートできます。外部ツールはデータベースに直接触れることなく読み書きできます。\n\n非公開のデータベースが引き続き正となります。フォルダにあるのは CSV のミラーで、データベースファイルではありません。 + セットアップ + 上でフォルダを選択してください。以降 Minus は 2 時間ごとに同期し、フォルダ内では自身の古いバックアップ以外が削除されることはありません。\n\n各実行はまずインポートし、その後エクスポートするため、読み取るファイルは常に直前に送った内容を反映します。\n\n1. minus_import_*.csv という名前のファイルをすべてインポートし、読み取り後に .imported または .rejected へ名前を変更します。\n2. minus_synced.csv を書き出し、直前 5 件のエクスポートを日時付きの minus_backup- コピーとして隣に残します。\n\nAndroid は Doze 中にバックグラウンド処理をまとめ、スケジューリングの下限は 15 分です。したがって 2 時間は目標であり保証ではありません。すぐに必要なときは手動トリガーを使ってください。 + adb コマンド + 新しい取引を送る + 待たずにすぐ同期する + 現在の状態を読む + フォルダのパスは選択したものに置き換えてください。上のコマンドにはこのビルドがインストールされるパッケージ名が既に入っているため、リリース版でもベータ版でも動作します。\n\nトリガーはアプリを前面に出します。ヘッドレス版はありません。ファイルパスや URI は一切含まず、既にアクセスを許可したフォルダをスキャンするようアプリに伝えるだけなので、ストレージ権限は不要です。\n\nフォルダを Syncthing や Dropbox のディレクトリに向けて、adb をまったく使わない運用も可能です。 + Minus が書き出すファイル + minus_synced.csv は 26 列のヘッダー全体を含み、実行ごとに書き直されます。直前 5 件のエクスポートは、置き換えられた時刻を付けた minus_backup- ファイルとして隣に保持されるため、エクスポートが壊れてもコピーがなくなることはありません。\n\nエクスポートには 3 種類の行が現れ、先頭の列で区別できます。\n\n__META__ は予算設定と現在の期間です。1 行のみで、ある場合は先頭に来ます。\n__ARCHIVED__ は終了した予算期間ごとに 1 行です。\nタイムスタンプは取引です。\n\n取引だけを読みたい場合は、この 2 つのマーカー行を飛ばす必要があります。__META__ 行の current_period_id の値が、新しい取引を書くときに period_id へ返す値です。\n\n再インストールして、すでに minus_synced.csv がある フォルダを Minus に指定すると、上書きではなくそのファイルから復元します。 + スクリプトが書き出すファイル + ファイル名は minus_import_<任意>.csv にしてください。Minus はフォルダ内の他のファイルをすべて無視するため、共有フォルダはそのまま保たれます。\n\nパーサーはファイルが宣言したヘッダーを読み取り、欠けている列はすべて空として扱うため、26 列すべては必要ありません。これは完全で有効なファイルです。 + date は必須で、厳密に yyyy-MM-dd HH:mm です。他の形式はその行を破棄します。\namount は必須で、0 より大きい単純な小数です。Minus の行は支出です。\ncategory は任意の自由テキストで、名前で照合され、新しければ作成されます。空欄は未分類になります。\nextra_note は任意の自由テキストです。元システムの取引 id を入れる場所に適しています。\nperiod_id は推奨で、__META__ 行の current_period_id からコピーします。空欄は 0 を意味します。\nid は省略してください。0 以外の値は既存の取引を上書きします。\nis_recurrent は 1 か 0 です。1 の場合は frequency を WEEKLY、BIWEEKLY、MONTHLY のいずれかにしてください。\nis_credit は 1 か 0 です。\n\nパーサーが拒否した行は 1 行ずつ飛ばされ、ファイルの残りはそのままインポートされます。 + 守るべきルール + 取り込むファイルは minus_import_*.csv という名前にしてください。それ以外は minus_synced.csv や minus_backup- のローテーションも含め、一切触りません。だからこそ、他のツールと共有しているフォルダに同期を向けても安全です。\n\n置いたものは削除されません。読み取り後、ファイルは .imported に、いずれかの行の解析に失敗した場合は .rejected に名前が変わり、内容はそのまま残るので何が問題だったか確認できます。どちらの名前も再度読み込まれることはありません。\n\n0 以外の id は絶対に書かないでください。id が 0 より大きい行は、その id を持つ取引を置き換えます。id が空または 0 の行は新規として挿入されます。エクスポートには実際の行 id が含まれるため、変更していないエクスポートを再送すると重複ではなく上書きになります。id 列を空にしない限り、エクスポートを新規行のテンプレートとして使うことはできません。\n\nperiod_id を設定してください。合計は日付から計算されるため、period_id が 0 の行でもどこでも正しく表示されます。より限定的な代償は、期間に紐づけられないことと、アーカイブ済み期間を削除したときに走るクリーンアップで残ってしまうことです。\n\nインポートは冪等ではありません。Minus は同等の取引があるか確認せず、読み取った行をすべて挿入します。インポート後の名前変更によって同じファイルが二度読まれることは防げますが、同じ行を別の名前で送れば二重に計上されるため、書き出す前に minus_synced.csv と照合して重複を除いてください。\n\n支出のみです。0 以下の金額は拒否されます。収入と予算の合計は __META__ 行にあり、それを書き換えると設定の完全復元が走るため、このチャネルは取引の追加専用として扱ってください。 + Open Finance フィードのマッピング + プロバイダーの日付を yyyy-MM-dd HH:mm に整形して date にマッピングします。金額は絶対値として amount にマッピングし、入金は飛ばします。プロバイダーのカテゴリ、または独自のルールを category にマッピングし、名前を正規化してください。そうしないとほぼ同じカテゴリが積み上がります。プロバイダーの取引 id は extra_note に入れると、アプリ上で見えたままになり、重複排除の安定した手がかりになります。\n\n2 時間ごとのループはこうなります。\n\n1. minus_synced.csv を取得します。\n2. __META__ と __ARCHIVED__ の行を飛ばして解析し、current_period_id を記録します。\n3. プロバイダーから新しい取引を取得します。それぞれについて、取得したファイルに同じ金額のものが自分の時間枠内にあるか、または extra_note に同じプロバイダー id があるかを確認します。\n4. 一致しなかったものを、上記 4 列に period_id を加えた minus_import_ ファイルに書き出します。\n5. ファイルを送り、同期トリガーを実行します。\n\n重複排除が必要なのは手順 3 です。Minus が代わりに行うことはありません。 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 5ad48e12..8dd16827 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -739,4 +739,60 @@ 반복되는 모든 항목과 기간당 비용입니다. 열면 각 항목의 다음 결제일을 보거나, 더 이상 내지 않는 반복을 중단할 수 있습니다. 다시 더해진 돈 앞에 +를 붙여 기록한 모든 것(급여, 환불, 입금)입니다. 지출로 계산되지 않고 이번 기간에 더해지므로 남은 예산이 늘어납니다. + 알림 + 실험적 기능 + 실험적 기능 + 알림에서 지출 리마인더 + 선택한 앱의 알림을 지켜보고, 결제처럼 보이는 알림이 오면 Minus에 기록하라고 알려줍니다. + 지출 리마인더 + Minus는 선택한 앱의 알림만 기기 안에서 읽고, 사용 중인 통화의 금액을 찾습니다. 직접 저장하기 전까지는 아무것도 저장되거나 전송되지 않습니다. + 알림 접근 권한 + Minus가 알림을 읽으려면 Android 권한이 필요합니다. 언제든지 취소할 수 있습니다. + 알림 접근 설정 열기 + 알림 지켜보기 + 리마인더를 켭니다. Minus는 아래에서 선택한 앱만 확인하고 다른 모든 알림은 무시합니다. + 지켜볼 앱 + 검색과 일치하는 앱이 없습니다. + 리마인더가 작동하려면 은행 앱처럼 최소 한 개의 앱을 선택하세요. + 앱 검색 + %1$d개 선택됨 + 지출 리마인더 + 다른 앱 알림에서 Minus가 찾은 결제를 기록하라는 리마인더입니다. + 방금 %1$s을 지출한 것 같습니다. Minus에서 빼는 것을 잊지 마세요. + 기록하기 + 그대로 저장 + 실행 취소 + Minus에 저장됨 + %1$s이(가) 카테고리 없이 저장되었습니다. + %1$s이(가) 다음 기간으로 저장되었습니다. + 환불,취소,입금,이체입금,잔액,캐시백,otp,인증번호,할인,이벤트 + 동기화 폴더 + CSV 복사본을 보관할 폴더를 선택하세요 + 2시간마다 %1$s로 동기화 중 + 아직 동기화가 실행되지 않았습니다 + 마지막 동기화 %1$s, %2$s개 행 가져옴 + 폴더 %1$s에서 복원했습니다 + 마지막 동기화 %1$s에서 이 파일들을 보관했고, 일부 행은 거부되었습니다: %2$s + 마지막 동기화가 실패했습니다 %1$s: %2$s + 폴더 접근 권한이 취소되었습니다. 폴더를 다시 선택하세요 + CSV 폴더 동기화 가이드 + 외부 스크립트에서 Minus 데이터를 읽고 쓰는 방법 + CSV 폴더 동기화 + Minus는 선택한 폴더에 데이터의 CSV 복사본을 보관하고, 같은 폴더에 넣은 CSV 파일을 가져올 수 있습니다. 외부 도구는 데이터베이스를 직접 건드리지 않고 읽기와 쓰기 권한을 얻습니다.\n\n비공개 데이터베이스가 여전히 기준입니다. 폴더에는 CSV 사본이 있을 뿐, 데이터베이스 파일이 있는 것은 아닙니다. + 설정 + 위에서 폴더를 선택하세요. 그때부터 Minus는 두 시간마다 동기화하며, 폴더에서는 자신의 오래된 백업을 제외하고 어떤 것도 삭제되지 않습니다.\n\n각 실행은 먼저 가져오고 그다음 내보내므로, 읽는 파일은 항상 방금 보낸 내용을 반영합니다.\n\n1. minus_import_*.csv라는 이름의 모든 파일을 가져오고, 읽은 뒤 각각 .imported 또는 .rejected로 이름을 바꿉니다.\n2. minus_synced.csv를 기록하고, 이전 다섯 개의 내보내기를 날짜가 붙은 minus_backup- 복사본으로 옆에 보관합니다.\n\nAndroid는 Doze 동안 백그라운드 작업을 한꺼번에 처리하고 예약 최소 간격은 15분이므로, 두 시간은 목표일 뿐 보장은 아닙니다. 지금 당장 필요하면 수동 트리거를 사용하세요. + adb 명령 + 새 거래 전송 + 기다리지 않고 즉시 동기화 + 현재 상태 읽기 + 폴더 경로를 선택한 경로로 바꾸세요. 위 명령에는 이 빌드가 설치되는 패키지 이름이 이미 들어 있어서, 정식 버전이든 베타든 그대로 동작합니다.\n\n트리거는 앱을 포그라운드로 가져옵니다. 화면 없이 동작하는 변형은 없습니다. 파일 경로나 URI를 전달하지 않고, 이미 접근을 허용한 폴더를 훑어보라고 앱에 알리기만 하므로 저장소 권한이 필요하지 않습니다.\n\n폴더를 Syncthing이나 Dropbox 디렉터리로 지정해서 adb를 아예 쓰지 않을 수도 있습니다. + Minus가 기록하는 파일 + minus_synced.csv는 26개 열 전체 헤더를 담고 있으며 실행마다 다시 기록됩니다. 이전 다섯 개의 내보내기는 교체된 시각이 찍힌 minus_backup- 파일로 옆에 보관되므로, 잘못된 내보내기 때문에 복사본이 없어지는 일은 없습니다.\n\n내보내기에는 세 종류의 행이 나타나며 첫 번째 열로 구분됩니다.\n\n__META__는 예산 설정과 현재 기간입니다. 한 행이며, 있으면 맨 앞에 옵니다.\n__ARCHIVED__는 종료된 예산 기간마다 한 행입니다.\n타임스탬프는 거래입니다.\n\n거래만 필요한 쪽은 이 두 표식 행을 건너뛰어야 합니다. __META__ 행의 current_period_id 값이 새 거래를 기록할 때 period_id로 되돌려 줄 값입니다.\n\n다시 설치한 뒤 이미 minus_synced.csv가 있는 폴더를 Minus에 지정하면, 덮어쓰지 않고 그 파일에서 복원합니다. + 스크립트가 기록하는 파일 + 파일 이름은 minus_import_<아무거나>.csv여야 합니다. Minus는 폴더의 다른 모든 파일을 무시하므로 공유 폴더는 그대로 유지됩니다.\n\n파서는 파일이 선언한 헤더를 읽고 빠진 열은 모두 비어 있는 것으로 처리하므로 26개 전부가 필요하지는 않습니다. 다음은 완전하고 유효한 파일입니다. + date는 필수이며 정확히 yyyy-MM-dd HH:mm 형식입니다. 다른 형태는 그 행을 버립니다.\namount는 필수이며 0보다 큰 단순 소수입니다. Minus의 행은 지출입니다.\ncategory는 선택적 자유 텍스트로, 이름으로 대조되고 새 이름이면 생성됩니다. 비어 있으면 미분류로 처리됩니다.\nextra_note는 선택적 자유 텍스트입니다. 원본 거래 id를 넣기에 좋은 자리입니다.\nperiod_id는 권장되며, __META__ 행의 current_period_id에서 복사합니다. 비어 있으면 0입니다.\nid는 넣지 않는 것이 좋습니다. 0이 아닌 값은 기존 거래를 덮어씁니다.\nis_recurrent는 1 또는 0입니다. 1이면 frequency를 WEEKLY, BIWEEKLY, MONTHLY 중 하나로 지정하세요.\nis_credit는 1 또는 0입니다.\n\n파서가 거부한 행은 하나씩 건너뛰고, 파일의 나머지는 그대로 가져옵니다. + 지켜야 할 규칙 + 들어오는 파일 이름은 minus_import_*.csv로 지으세요. minus_synced.csv와 minus_backup- 순환 파일을 포함해 그 밖의 모든 것은 건드리지 않습니다. 그래서 다른 도구와 공유하는 폴더로 동기화를 지정해도 안전합니다.\n\n넣어 둔 것은 삭제되지 않습니다. 읽은 뒤 파일 이름은 .imported로, 어떤 행이 파싱에 실패했다면 .rejected로 바뀌며, 내용은 그대로 남아 무엇이 잘못됐는지 확인할 수 있습니다. 두 이름 모두 다시 읽히지 않습니다.\n\n0이 아닌 id는 절대 기록하지 마세요. id가 0보다 큰 행은 그 id를 가진 거래를 대체하고, id가 비었거나 0인 행은 새로 삽입됩니다. 내보내기에는 실제 행 id가 담겨 있으므로, 수정하지 않은 내보내기를 다시 보내면 중복이 아니라 덮어쓰기가 됩니다. id 열을 먼저 비우지 않으면 내보내기를 새 행의 템플릿으로 쓸 수 없습니다.\n\nperiod_id를 설정하세요. 합계는 날짜로 계산되므로 period_id가 0인 행도 모든 화면에서 올바르게 표시됩니다. 더 좁은 대가는 기간에 귀속되지 않고, 보관된 기간을 삭제할 때 실행되는 정리에서 살아남는다는 점입니다.\n\n가져오기는 멱등하지 않습니다. Minus는 동등한 거래가 있는지 확인하지 않고 읽은 모든 행을 삽입합니다. 가져오기 후 이름 변경으로 같은 파일이 두 번 읽히는 일은 막지만, 같은 행을 새 이름으로 보내면 두 번 집계되므로 기록하기 전에 minus_synced.csv와 비교해 중복을 제거하세요.\n\n지출만 가능합니다. 0 이하 금액은 거부됩니다. 수입과 예산 합계는 __META__ 행에 있고, 그것을 다시 쓰면 설정 전체 복원이 실행되므로 이 경로는 거래 추가 전용으로 다루세요. + Open Finance 피드 매핑 + 제공자의 날짜를 yyyy-MM-dd HH:mm으로 다시 포맷해 date에 매핑하세요. 금액은 절댓값으로 amount에 매핑하고 입금은 건너뜁니다. 제공자의 카테고리 또는 직접 만든 규칙을 category에 매핑하되 이름을 정규화하세요. 그러지 않으면 거의 같은 이름이 쌓입니다. 제공자의 거래 id는 extra_note에 넣어 두면 앱에서 계속 보이고, 중복 제거에 안정적인 기준이 됩니다.\n\n두 시간 주기는 이렇게 돌아갑니다.\n\n1. minus_synced.csv를 받습니다.\n2. __META__와 __ARCHIVED__ 행을 건너뛰고 파싱한 뒤 current_period_id를 기록합니다.\n3. 제공자에서 새 거래를 가져옵니다. 각각에 대해, 받은 파일에 같은 금액이 설정한 시간 범위 안에 이미 있는지, 또는 extra_note에 같은 제공자 id가 있는지 확인합니다.\n4. 일치하지 않은 것들을 위의 네 열에 period_id를 더해 minus_import_ 파일로 기록합니다.\n5. 파일을 보낸 뒤 동기화 트리거를 실행합니다.\n\n중복 제거가 일어나야 하는 곳은 3단계입니다. Minus가 대신 해 주지 않습니다. diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 2119e0a4..30e2ae63 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -540,7 +540,7 @@ Despesas únicas Se você economizar %1$d%% por cerca de 6 meses, você pode construir: Para alcançar %1$s em %2$d meses, economize %3$s por período. - Mostrando o exemplo padrão %1$d%. Defina um objetivo de poupança nas configurações para atingir um valor específico. + Mostrando o exemplo padrão %1$d%%. Defina um objetivo de poupança nas configurações para atingir um valor específico. Preferências de poupança Personalize a divisão 50/30/20 e defina um objetivo de poupança opcional @@ -738,4 +738,60 @@ Tudo o que se repete, com quanto custa por período. Abra para ver a próxima data de vencimento de cada uma ou para encerrar uma série que você não paga mais. Dinheiro devolvido Tudo o que você registrou começando com + — um salário, um reembolso, uma transferência recebida. É somado a este período em vez de contar como gasto, então seu orçamento restante aumenta. + Notificações + Experimental + Experimental + Lembretes de gasto por notificação + Observa as notificações dos apps que você escolher e, quando uma parece uma cobrança, lembra você de registrá-la no Minus. + Lembretes de gasto + O Minus lê as notificações apenas dos apps que você escolher, somente no seu dispositivo, e procura um valor na sua moeda. Nada é armazenado nem enviado a lugar algum até você mesmo salvar. + Acesso às notificações + O Android precisa da sua permissão antes de o Minus poder ler notificações. Você pode revogá-la a qualquer momento. + Abrir acesso às notificações + Observar notificações + Ativa o lembrete. O Minus olha apenas os apps selecionados abaixo e ignora todas as outras notificações. + Apps a observar + Nenhum app corresponde à sua busca. + Escolha pelo menos um app, como o do seu banco, para o lembrete fazer algo. + Buscar apps + %1$d selecionados + Lembretes de gasto + Lembretes para registrar uma cobrança que o Minus encontrou na notificação de outro app. + Parece que você acabou de gastar %1$s. Não esqueça de subtrair. + Subtrair + Salvar como está + Desfazer + Salvo no Minus + %1$s salvo sem categoria. + %1$s salvo para o seu próximo período. + reembolso,reembolsado,estorno,crédito de,depósito,saldo,cashback,otp,código de verificação,desconto,promoção + Pasta de sincronização + Escolha uma pasta para manter uma cópia em CSV + Sincronizando com %1$s a cada 2 horas + Nenhuma sincronização foi executada ainda + Última sincronização %1$s, %2$s linhas importadas + Restaurado a partir da pasta %1$s + A última sincronização %1$s manteve estes arquivos; algumas linhas foram rejeitadas: %2$s + A última sincronização falhou %1$s: %2$s + O acesso à pasta foi revogado, escolha a pasta novamente + Guia de sincronização por pasta CSV + Como ler e gravar os dados do Minus a partir de um script externo + Sincronização por pasta CSV + O Minus pode manter uma cópia em CSV dos seus dados em uma pasta que você escolher e importar os arquivos CSV que você colocar nessa mesma pasta. Ferramentas externas ganham acesso de leitura e gravação sem mexer diretamente no banco de dados.\n\nO banco de dados privado continua sendo a fonte da verdade. A pasta guarda um espelho em CSV, não o arquivo do banco de dados. + Configuração + Escolha uma pasta acima. A partir daí o Minus sincroniza a cada duas horas, e nada na pasta é apagado, exceto os backups antigos dele mesmo.\n\nCada execução importa primeiro e exporta depois, então o arquivo que você lê sempre reflete o que você acabou de enviar.\n\n1. Importa todos os arquivos chamados minus_import_*.csv, renomeando cada um para .imported ou .rejected depois de lido.\n2. Grava minus_synced.csv, mantendo ao lado as cinco exportações anteriores como cópias minus_backup- datadas.\n\nO Android agrupa o trabalho em segundo plano durante o Doze e o mínimo de agendamento é de 15 minutos, então duas horas é uma meta, não uma garantia. Use o acionador manual quando precisar agora. + Comandos adb + Enviar novas transações + Sincronizar imediatamente em vez de esperar + Ler o estado atual + Substitua o caminho da pasta pelo que você escolheu. O comando acima já indica o pacote com que esta compilação é instalada, então funciona tanto em uma versão final quanto em uma beta.\n\nO acionador traz o app para o primeiro plano; não há variante sem interface. Ele não carrega nenhum caminho de arquivo nem URI, apenas diz ao app para varrer a pasta à qual você já deu acesso, por isso não precisa de permissão de armazenamento.\n\nVocê também pode apontar a pasta para um diretório do Syncthing ou do Dropbox e dispensar o adb por completo. + Os arquivos que o Minus grava + minus_synced.csv traz o cabeçalho completo de 26 colunas e é reescrito a cada execução. As cinco exportações anteriores ficam ao lado dele como arquivos minus_backup- marcados com a hora em que foram substituídas, então uma exportação ruim nunca deixa você sem cópia.\n\nTrês tipos de linha aparecem em uma exportação, distinguidos pela primeira coluna:\n\n__META__ são as configurações do orçamento e o período atual. Uma linha, a primeira se estiver presente.\n__ARCHIVED__ é uma linha por período de orçamento encerrado.\nUm timestamp é uma transação.\n\nUm leitor que queira apenas transações precisa pular as duas linhas marcadoras. O valor current_period_id na linha __META__ é o que você devolve em period_id ao gravar novas transações.\n\nSe você reinstalar e apontar o Minus para uma pasta que já tem um minus_synced.csv, ele restaura a partir desse arquivo em vez de sobrescrevê-lo. + O arquivo que o seu script grava + Seu arquivo precisa se chamar minus_import_<qualquer coisa>.csv. O Minus ignora todos os outros arquivos da pasta, então uma pasta compartilhada fica intocada.\n\nO analisador lê o cabeçalho que o seu arquivo declarar e trata toda coluna ausente como vazia, então você não precisa das 26. Este é um arquivo completo e válido. + date é obrigatório, exatamente yyyy-MM-dd HH:mm. Qualquer outro formato descarta a linha.\namount é obrigatório, um decimal simples maior que zero. As linhas do Minus são despesas.\ncategory é texto livre opcional, correspondido por nome e criado se for novo. Em branco cai para Descategorizado.\nextra_note é texto livre opcional. Bom lugar para o id da transação de origem.\nperiod_id é recomendado, copiado de current_period_id na linha __META__. Em branco significa 0.\nid deve ser omitido. Um valor diferente de zero sobrescreve uma transação existente.\nis_recurrent é 1 ou 0. Se for 1, defina frequency como WEEKLY, BIWEEKLY ou MONTHLY.\nis_credit é 1 ou 0.\n\nAs linhas que o analisador rejeita são ignoradas uma a uma; o resto do arquivo é importado normalmente. + Regras a respeitar + Nomeie os arquivos de entrada como minus_import_*.csv. Qualquer outro é deixado em paz, inclusive minus_synced.csv e as rotações minus_backup-. É isso que torna seguro apontar a sincronização para uma pasta compartilhada com outras ferramentas.\n\nNada do que você colocar ali é apagado. Depois de lido, o arquivo é renomeado para .imported, ou para .rejected se alguma de suas linhas falhar na análise, mantendo o conteúdo para você ver o que deu errado. Nenhum dos dois nomes é lido de novo.\n\nNunca grave um id diferente de zero. Linhas com id maior que zero substituem a transação que tem aquele id; linhas com id em branco ou zero são inseridas como novas. Como a exportação carrega os ids reais das linhas, reenviar uma exportação sem alterações sobrescreve em vez de duplicar. Você não pode usar uma exportação como modelo para novas linhas sem antes limpar a coluna id.\n\nDefina period_id. Os totais são calculados a partir das datas, então uma linha com period_id 0 ainda aparece corretamente em todo lugar. O custo, mais restrito, é que ela não pode ser atribuída a um período e sobrevive à limpeza executada quando um período arquivado é excluído.\n\nA importação não é idempotente. O Minus insere cada linha que lê sem verificar se existe uma transação equivalente. A renomeação após a importação impede que o mesmo arquivo seja lido duas vezes, mas enviar as mesmas linhas com outro nome vai contá-las em dobro, então remova duplicatas comparando com minus_synced.csv antes de gravar.\n\nApenas despesas. Valores de zero ou menos são rejeitados. Receitas e totais do orçamento ficam na linha __META__, e reescrevê-los dispara uma restauração completa das configurações, então trate este canal como somente de acréscimo para transações. + Mapear um feed de Open Finance + Mapeie a data do provedor para date, reformatada como yyyy-MM-dd HH:mm. Mapeie o valor para amount como valor absoluto, ignorando entradas de dinheiro. Mapeie a categoria do provedor, ou a sua própria regra, para category, normalizando os nomes ou você vai acumular quase duplicatas. Coloque o id da transação do provedor em extra_note para que ele fique visível no app e dê à sua deduplicação uma referência estável.\n\nUm ciclo de duas horas fica assim:\n\n1. Baixe minus_synced.csv.\n2. Analise o arquivo, pulando as linhas __META__ e __ARCHIVED__, e anote current_period_id.\n3. Busque as novas transações do provedor. Para cada uma, verifique se o arquivo baixado já tem uma com o mesmo valor dentro da sua janela de tempo, ou o mesmo id do provedor em extra_note.\n4. Grave as não correspondentes em um arquivo minus_import_ com as quatro colunas acima mais period_id.\n5. Envie o arquivo e então acione o gatilho de sincronização.\n\nO passo 3 é onde a deduplicação precisa acontecer. O Minus não vai fazer isso por você. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index f4d26c1e..b5bad94b 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -766,4 +766,60 @@ Всё, что повторяется, и сколько это стоит за период. Откройте, чтобы увидеть ближайшую дату списания по каждому или остановить серию, которую вы больше не оплачиваете. Возвращённые деньги Всё, что вы записали со знаком + в начале: зарплата, возврат, входящий перевод. Это добавляется к периоду, а не считается тратой, поэтому остаток бюджета растёт. + Уведомления + Экспериментально + Экспериментально + Напоминания о расходах из уведомлений + Следит за уведомлениями выбранных вами приложений и, если уведомление похоже на списание, напоминает внести его в Minus. + Напоминания о расходах + Minus читает уведомления только выбранных вами приложений и только на вашем устройстве, находя в них сумму в вашей валюте. Ничего не сохраняется и никуда не отправляется, пока вы сами это не сохраните. + Доступ к уведомлениям + Android требует вашего разрешения, чтобы Minus мог читать уведомления. Вы можете отозвать его в любой момент. + Открыть доступ к уведомлениям + Следить за уведомлениями + Включает напоминание. Minus смотрит только на выбранные ниже приложения и игнорирует все остальные уведомления. + Приложения для наблюдения + Нет приложений, подходящих под запрос. + Выберите хотя бы одно приложение, например банковское, чтобы напоминание работало. + Поиск приложений + Выбрано: %1$d + Напоминания о расходах + Напоминания внести списание, которое Minus нашёл в уведомлении другого приложения. + Похоже, вы только что потратили %1$s. Не забудьте вычесть это. + Вычесть + Сохранить как есть + Отменить + Сохранено в Minus + %1$s сохранено без категории. + %1$s сохранено на следующий период. + возврат,зачисление,зачислено,пополнение,баланс,остаток,кэшбэк,otp,код подтверждения,скидка,промокод + Папка синхронизации + Выберите папку для хранения копии в CSV + Синхронизация в %1$s каждые 2 часа + Синхронизация ещё не выполнялась + Последняя синхронизация %1$s, импортировано строк: %2$s + Восстановлено из папки %1$s + Последняя синхронизация %1$s сохранила эти файлы, часть строк отклонена: %2$s + Последняя синхронизация не удалась %1$s: %2$s + Доступ к папке отозван, выберите папку заново + Руководство по синхронизации с CSV-папкой + Как читать и записывать данные Minus из внешнего скрипта + Синхронизация с CSV-папкой + Minus может хранить копию своих данных в формате CSV в выбранной вами папке и импортировать CSV-файлы, которые вы в неё положите. Внешние инструменты получают доступ на чтение и запись, не касаясь базы данных напрямую.\n\nЗакрытая база данных остаётся источником истины. В папке лежит CSV-зеркало, а не файл базы данных. + Настройка + Выберите папку выше. С этого момента Minus синхронизируется каждые два часа, и в папке никогда ничего не удаляется, кроме его собственных старых резервных копий.\n\nКаждый запуск сначала импортирует, затем экспортирует, поэтому файл, который вы читаете, всегда отражает то, что вы только что отправили.\n\n1. Импортирует каждый файл с именем minus_import_*.csv, переименовывая его в .imported или .rejected после прочтения.\n2. Записывает minus_synced.csv, сохраняя рядом пять предыдущих экспортов как копии minus_backup- с датой.\n\nAndroid группирует фоновую работу в режиме Doze, а минимальный интервал планирования — 15 минут, поэтому два часа — это цель, а не гарантия. Используйте ручной запуск, когда нужно прямо сейчас. + Команды adb + Отправить новые операции + Синхронизировать сразу, не дожидаясь + Прочитать текущее состояние + Замените путь к папке на тот, который вы выбрали. В команде выше уже указан пакет, под которым устанавливается эта сборка, поэтому она работает и для релиза, и для беты.\n\nЗапуск выводит приложение на передний план; варианта без интерфейса нет. Он не передаёт ни путь к файлу, ни URI, а только говорит приложению просмотреть папку, доступ к которой вы уже выдали, поэтому разрешение на хранилище не нужно.\n\nМожно также указать папку Syncthing или Dropbox и полностью обойтись без adb. + Файлы, которые записывает Minus + minus_synced.csv содержит полный заголовок из 26 столбцов и перезаписывается при каждом запуске. Пять предыдущих экспортов хранятся рядом как файлы minus_backup- с отметкой времени замены, поэтому неудачный экспорт никогда не оставит вас без копии.\n\nВ экспорте встречаются три типа строк, различаемых по первому столбцу:\n\n__META__ — настройки бюджета и текущий период. Одна строка, первая, если она есть.\n__ARCHIVED__ — по одной строке на каждый закрытый период бюджета.\nМетка времени — это операция.\n\nЧитатель, которому нужны только операции, должен пропустить две служебные строки. Значение current_period_id из строки __META__ — это то, что вы возвращаете в period_id при записи новых операций.\n\nЕсли вы переустановите приложение и укажете Minus папку, в которой уже есть minus_synced.csv, он восстановится из этого файла, а не перезапишет его. + Файл, который записывает ваш скрипт + Ваш файл должен называться minus_import_<что угодно>.csv. Minus игнорирует все остальные файлы в папке, поэтому общая папка остаётся нетронутой.\n\nПарсер читает тот заголовок, который объявлен в вашем файле, и считает каждый отсутствующий столбец пустым, так что все 26 не нужны. Вот полный и корректный файл. + date обязателен, строго yyyy-MM-dd HH:mm. Любой другой формат отбрасывает строку.\namount обязателен, обычное десятичное число больше нуля. Строки Minus — это расходы.\ncategory — необязательный произвольный текст, сопоставляется по имени и создаётся, если новый. Пустое значение даёт Без категории.\nextra_note — необязательный произвольный текст. Хорошее место для id операции во внешней системе.\nperiod_id рекомендуется, копируется из current_period_id в строке __META__. Пустое значение означает 0.\nid следует не указывать. Значение, отличное от нуля, перезаписывает существующую операцию.\nis_recurrent — 1 или 0. Если 1, задайте frequency как WEEKLY, BIWEEKLY или MONTHLY.\nis_credit — 1 или 0.\n\nСтроки, отклонённые парсером, пропускаются по одной; остальная часть файла всё равно импортируется. + Правила, которые нужно соблюдать + Называйте входящие файлы minus_import_*.csv. Всё остальное не трогается, включая minus_synced.csv и ротации minus_backup-. Именно поэтому безопасно направлять синхронизацию в папку, которую вы делите с другими инструментами.\n\nНичего из того, что вы положили, не удаляется. После прочтения файл переименовывается в .imported или в .rejected, если какая-то из строк не разобралась, с сохранением содержимого, чтобы вы увидели, что пошло не так. Ни одно из этих имён больше не читается.\n\nНикогда не записывайте id, отличный от нуля. Строки с id больше нуля заменяют операцию с этим id; строки с пустым или нулевым id добавляются как новые. Поскольку экспорт содержит реальные id строк, повторная отправка неизменённого экспорта перезаписывает, а не дублирует. Нельзя использовать экспорт как шаблон для новых строк, не очистив сначала столбец id.\n\nЗадавайте period_id. Итоги считаются по датам, поэтому строка с period_id 0 всё равно отображается верно везде. Более узкая цена в том, что её нельзя отнести к периоду и она выживает при очистке, которая выполняется при удалении архивного периода.\n\nИмпорт не идемпотентен. Minus вставляет каждую прочитанную строку, не проверяя, нет ли равнозначной операции. Переименование после импорта не даёт прочитать один и тот же файл дважды, но отправка тех же строк под новым именем посчитает их повторно, поэтому удаляйте дубликаты по minus_synced.csv перед записью.\n\nТолько расходы. Суммы, равные нулю или меньше, отклоняются. Доходы и итоги бюджета находятся в строке __META__, и их перезапись запускает полное восстановление настроек, поэтому считайте этот канал только добавляющим операции. + Сопоставление потока Open Finance + Сопоставьте дату поставщика с date, переформатировав её в yyyy-MM-dd HH:mm. Сопоставьте сумму с amount по абсолютному значению, пропуская поступления. Сопоставьте категорию поставщика или своё правило с category, нормализуя имена, иначе вы накопите почти-дубликаты. Положите id операции поставщика в extra_note, чтобы он оставался виден в приложении и давал вашему устранению дубликатов устойчивую привязку.\n\nДвухчасовой цикл выглядит так:\n\n1. Загрузите minus_synced.csv.\n2. Разберите его, пропустив строки __META__ и __ARCHIVED__, и запомните current_period_id.\n3. Получите новые операции у поставщика. Для каждой проверьте, нет ли в загруженном файле операции с той же суммой в вашем окне времени или с тем же id поставщика в extra_note.\n4. Запишите несовпавшие в файл minus_import_ с четырьмя столбцами выше плюс period_id.\n5. Отправьте файл, затем запустите синхронизацию.\n\nШаг 3 — это то место, где должно происходить устранение дубликатов. Minus не сделает это за вас. diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index cb38216e..340eb86c 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -18,7 +18,7 @@ 用手盖住屏幕顶部听筒附近约一秒,所有金额都会模糊。再盖一次即可恢复显示。 收入与调整 金额以 + 开头,可把钱加回这个周期,比如发薪、退款或转入。以 - 开头则扣减金额。其余按键作为普通计算器使用。 - Credit Card payment + 信用卡支付 把这笔支出标记为信用卡支付。它仍计入本周期,但 Minus 会单独记录你的欠款,方便你在账单日前结清。 今日 昨天 @@ -267,7 +267,7 @@ 发行日期 %1$s 关闭 上一版本 - Updated to: %1$s + 已更新至:%1$s Minus 已更新。<br>如需了解更多详情,请访问 <a href="https://github.com/isaacsa51/Minus/wiki">Wiki</a>。 选择周期 @@ -298,8 +298,8 @@ 存钱 主题 更改应用主题风格 - Theming and color - Change the color and appearance of the app + 主题与配色 + 更改应用的配色与外观 小组件 探索可添加到主屏幕的小组件 排版 @@ -342,8 +342,8 @@ 点击时显示类别建议 类别列表模式 加载过去的周期 - Show past transactions - Load transactions from past periods + 显示过往交易 + 加载过往周期的交易 周期结束时间 周期截止日期的通知将于此刻发送 定期支出时间 @@ -737,4 +737,60 @@ 所有重复的项目及其每个周期的花费。打开可查看每一项的下次到期日,或停止你不再支付的项目。 加回的钱 所有以 + 开头记录的条目——发薪、退款、转入。它会计入本周期而不算作支出,因此你的剩余预算会增加。 + 通知 + 实验性功能 + 实验性功能 + 来自通知的支出提醒 + 监测你选择的应用的通知,当某条通知看起来像一笔消费时,提醒你记入 Minus。 + 支出提醒 + Minus 仅在你的设备上读取你所选应用的通知,并从中寻找你所用货币的金额。在你自己保存之前,任何内容都不会被存储或发送到任何地方。 + 通知使用权 + Minus 需要 Android 的授权才能读取通知。你可以随时撤销。 + 打开通知使用权 + 监测通知 + 开启提醒。Minus 只查看下方选中的应用,忽略其他所有通知。 + 要监测的应用 + 没有与搜索匹配的应用。 + 请至少选择一个应用,例如你的银行应用,提醒才会生效。 + 搜索应用 + 已选 %1$d 个 + 支出提醒 + 提醒你记录 Minus 在其他应用通知中发现的一笔消费。 + 看起来你刚花了 %1$s。别忘了在 Minus 里减掉。 + 记一笔 + 原样保存 + 撤销 + 已保存到 Minus + %1$s 已保存,未设置分类。 + %1$s 已保存到下一个周期。 + 退款,退回,入账,存入,余额,返现,otp,验证码,折扣,优惠 + 同步文件夹 + 选择一个文件夹来保存 CSV 副本 + 每 2 小时同步到 %1$s + 尚未执行过同步 + 上次同步 %1$s,导入 %2$s 行 + 已从文件夹 %1$s 恢复 + 上次同步 %1$s 保留了这些文件,部分行被拒绝:%2$s + 上次同步失败 %1$s:%2$s + 文件夹访问权限已被撤销,请重新选择文件夹 + CSV 文件夹同步指南 + 如何通过外部脚本读写 Minus 的数据 + CSV 文件夹同步 + Minus 可以把数据的 CSV 副本保存在你选择的文件夹里,并导入你放进同一文件夹的 CSV 文件。外部工具无需直接接触数据库即可获得读写权限。\n\n私有数据库仍然是事实来源。文件夹里放的是 CSV 镜像,而不是数据库文件。 + 设置 + 在上面选择一个文件夹。从那时起 Minus 每两小时同步一次,文件夹里除了它自己的旧备份之外,任何内容都不会被删除。\n\n每次运行先导入后导出,所以你读到的文件总能反映你刚刚推送的内容。\n\n1. 导入每个名为 minus_import_*.csv 的文件,读取后将其重命名为 .imported 或 .rejected。\n2. 写入 minus_synced.csv,并把此前五次导出以带日期的 minus_backup- 副本形式保留在旁边。\n\nAndroid 在 Doze 期间会批量处理后台任务,且调度下限为 15 分钟,所以两小时是目标而非保证。需要立刻同步时请使用手动触发。 + adb 命令 + 推送新交易 + 立即同步而不必等待 + 读取当前状态 + 把文件夹路径替换成你选择的那个。上面的命令已经写明了此构建安装时使用的包名,所以无论你运行的是正式版还是测试版都适用。\n\n触发会把应用带到前台;没有无界面的变体。它不携带任何文件路径或 URI,只是告诉应用去扫描你已经授权的文件夹,因此不需要存储权限。\n\n你也可以把文件夹指向 Syncthing 或 Dropbox 目录,从而完全不用 adb。 + Minus 写出的文件 + minus_synced.csv 带有完整的 26 列表头,每次运行都会重写。此前五次导出会以标记了被替换时间的 minus_backup- 文件保留在旁边,所以一次糟糕的导出绝不会让你没有副本。\n\n导出中会出现三种行,通过第一列区分:\n\n__META__ 是预算设置和当前周期。只有一行,若存在则排在最前。\n__ARCHIVED__ 是每个已结束预算周期一行。\n时间戳则表示一笔交易。\n\n只想读取交易的程序必须跳过这两行标记行。__META__ 行中的 current_period_id 就是你写入新交易时要回填到 period_id 的值。\n\n如果你重新安装并把 Minus 指向一个已经包含 minus_synced.csv 的文件夹,它会从该文件恢复,而不是覆盖它。 + 你的脚本写出的文件 + 你的文件必须命名为 minus_import_<任意内容>.csv。Minus 会忽略文件夹中的其他所有文件,所以共享文件夹不会被动到。\n\n解析器会读取你的文件所声明的表头,并把每个缺失的列视为空,所以你不需要全部 26 列。下面就是一个完整有效的文件。 + date 为必填,必须正好是 yyyy-MM-dd HH:mm。其他任何格式都会丢弃该行。\namount 为必填,是大于零的普通小数。Minus 的行都是支出。\ncategory 为可选的自由文本,按名称匹配,若为新名称则创建。留空则归为未分类。\nextra_note 为可选的自由文本。适合放上游交易的 id。\nperiod_id 建议填写,从 __META__ 行的 current_period_id 复制。留空表示 0。\nid 应当省略。非零值会覆盖已存在的交易。\nis_recurrent 为 1 或 0。若为 1,请把 frequency 设为 WEEKLY、BIWEEKLY 或 MONTHLY。\nis_credit 为 1 或 0。\n\n解析器拒绝的行会被逐行跳过;文件的其余部分照常导入。 + 需要遵守的规则 + 把传入的文件命名为 minus_import_*.csv。其他一切都不会被触碰,包括 minus_synced.csv 和 minus_backup- 轮换文件。正因如此,把同步指向一个你与其他工具共享的文件夹才是安全的。\n\n你放进去的内容都不会被删除。文件读取后会被重命名为 .imported,若其中任何一行解析失败则重命名为 .rejected,内容会保留下来,方便你查看出了什么问题。这两种名称都不会再被读取。\n\n绝不要写入非零的 id。id 大于零的行会替换持有该 id 的交易;id 为空或为零的行会作为新记录插入。由于导出携带真实的行 id,重新推送一份未修改的导出会变成覆盖而不是重复。除非先清空 id 列,否则你无法把导出当作新行的模板。\n\n请设置 period_id。合计是按日期计算的,所以 period_id 为 0 的行在各处依然显示正确。较小的代价是它无法归属到某个周期,并且会在删除已归档周期时运行的清理中残留下来。\n\n导入不是幂等的。Minus 会插入它读到的每一行,而不检查是否已有等价的交易。导入后的重命名可以避免同一文件被读取两次,但用新名称推送相同的行会导致重复计入,所以写入前请对照 minus_synced.csv 去重。\n\n仅支持支出。金额为零或更小会被拒绝。收入和预算合计位于 __META__ 行,重写它们会触发完整的设置恢复,所以请把这个通道视为只能追加交易。 + 映射 Open Finance 数据源 + 把提供方的日期映射到 date,并重新格式化为 yyyy-MM-dd HH:mm。把金额以绝对值映射到 amount,跳过入账。把提供方的分类,或你自己的规则,映射到 category,并对名称做归一化,否则你会积累大量近似重复项。把提供方的交易 id 放进 extra_note,这样它在应用里仍然可见,也为你的去重提供稳定的依据。\n\n一个两小时的循环大致如下:\n\n1. 拉取 minus_synced.csv。\n2. 解析它,跳过 __META__ 和 __ARCHIVED__ 行,并记下 current_period_id。\n3. 获取提供方的新交易。对每一笔,检查拉取到的文件中是否已有在你的时间窗口内金额相同的记录,或 extra_note 中有相同的提供方 id。\n4. 把未匹配的写入一个 minus_import_ 文件,包含上面四列再加 period_id。\n5. 推送文件,然后触发同步。\n\n第 3 步才是去重必须发生的地方。Minus 不会替你完成。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7173f5af..9f789c95 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -439,6 +439,35 @@ Categories Transactions Budget & payments + Notifications + Experimental + Experimental + + Spend reminders from notifications + Watches the notifications of the apps you pick and, when one looks like a charge, reminds you to log it in Minus. + Spend reminders + Minus reads the notifications of the apps you pick, on your device only, and looks for an amount in your currency. Nothing is stored or sent anywhere until you save it yourself. + Notification access + Android needs your permission before Minus can read notifications. You can revoke it at any time. + Open notification access + Watch notifications + Turns the reminder on. Minus only looks at the apps selected below and ignores every other notification. + Apps to watch + No app matches your search. + Pick at least one app, like your bank, for the reminder to do anything. + Search apps + %1$d selected + Spend reminders + Reminders to log a charge Minus spotted in another app\'s notification. + Seems like you just spent %1$s. Don\'t forget to minus it. + Minus it + Save as is + Undo + Saved to Minus + %1$s saved without a category. + %1$s saved for your next period. + + refund,refunded,credited,deposit,deposited,balance,cashback,otp,one-time code,verification code,discount,promo Experimental On Off diff --git a/app/src/test/java/com/serranoie/app/minus/data/csv/MinusCsvServiceTest.kt b/app/src/test/java/com/serranoie/app/minus/data/csv/MinusCsvServiceTest.kt index 24b4d367..bf41ed42 100644 --- a/app/src/test/java/com/serranoie/app/minus/data/csv/MinusCsvServiceTest.kt +++ b/app/src/test/java/com/serranoie/app/minus/data/csv/MinusCsvServiceTest.kt @@ -44,7 +44,7 @@ class MinusCsvServiceTest { Category(id = CATEGORY_IDS[name] ?: 99L, name = name) } coEvery { repository.upsertTransactions(capture(upsertedTransactions)) } just Runs - coEvery { repository.addTransaction(capture(addedTransactions)) } just Runs + coEvery { repository.addTransaction(capture(addedTransactions)) } returns 1L coEvery { repository.upsertArchivedBudgets(capture(upsertedArchives)) } just Runs } diff --git a/app/src/test/java/com/serranoie/app/minus/data/repository/SettingsRepositoryImplTest.kt b/app/src/test/java/com/serranoie/app/minus/data/repository/SettingsRepositoryImplTest.kt index 6de4f845..f26376bc 100644 --- a/app/src/test/java/com/serranoie/app/minus/data/repository/SettingsRepositoryImplTest.kt +++ b/app/src/test/java/com/serranoie/app/minus/data/repository/SettingsRepositoryImplTest.kt @@ -243,6 +243,31 @@ class SettingsRepositoryImplTest { } } + @Test + fun `toggling a scanned package flips only that package in the stored set`() = runTest { + repo.setNotificationScanPackages(setOf("com.bank.one")) + + repo.toggleNotificationScanPackage("com.bank.two") + assertThat(repo.getSettings().notificationScanPackages) + .containsExactly("com.bank.one", "com.bank.two") + + repo.toggleNotificationScanPackage("com.bank.one") + assertThat(repo.getSettings().notificationScanPackages).containsExactly("com.bank.two") + } + + @Test + fun `toggling a scanned package reads the persisted set, not a stale copy`() = runTest { + repo.setNotificationScanPackages(setOf("com.bank.one")) + val stale = repo.getSettings().notificationScanPackages + + repo.toggleNotificationScanPackage("com.bank.two") + repo.toggleNotificationScanPackage("com.bank.three") + + assertThat(stale).containsExactly("com.bank.one") + assertThat(repo.getSettings().notificationScanPackages) + .containsExactly("com.bank.one", "com.bank.two", "com.bank.three") + } + @Test fun `observeSettings reflects a write made after the first read`() = runTest { assertThat(repo.observeSettings().first().onboardingCompleted).isFalse() diff --git a/app/src/test/java/com/serranoie/app/minus/domain/notification/ExpenseNotificationParserTest.kt b/app/src/test/java/com/serranoie/app/minus/domain/notification/ExpenseNotificationParserTest.kt new file mode 100644 index 00000000..8155c7b9 --- /dev/null +++ b/app/src/test/java/com/serranoie/app/minus/domain/notification/ExpenseNotificationParserTest.kt @@ -0,0 +1,98 @@ +package com.serranoie.app.minus.domain.notification + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.math.BigDecimal + +class ExpenseNotificationParserTest { + + private val deny = listOf("refund", "credited", "deposit", "balance", "cashback", "otp") + + private fun parse(text: String, currency: String = "USD") = + ExpenseNotificationParser.parse(text, currency, deny) + + @Test + fun parses_unique_symbol_before_amount() { + assertEquals(BigDecimal("42.90"), parse("Purchase approved £42.90 at STARBUCKS", "GBP")) + } + + @Test + fun parses_iso_code_after_amount() { + assertEquals(BigDecimal("12.00"), parse("You spent 12.00 USD at the shop")) + } + + @Test + fun parses_comma_decimal_and_dot_grouping() { + assertEquals(BigDecimal("1234.56"), parse("Compra de R$ 1.234,56 no cartao", "BRL")) + } + + @Test + fun parses_dot_decimal_and_comma_grouping() { + assertEquals(BigDecimal("1234.56"), parse("Charged 1,234.56 USD today")) + } + + @Test + fun treats_three_trailing_digits_as_grouping() { + assertEquals(BigDecimal("1234"), parse("Pago de 1.234 € en tienda", "EUR")) + } + + @Test + fun ignores_income_sign() { + assertNull(parse("You received +£1,200.00 from Ana", "GBP")) + } + + @Test + fun ignores_denied_words() { + assertNull(parse("Refund of £42.90 processed", "GBP")) + assertNull(parse("Your balance is £1,500.00", "GBP")) + } + + @Test + fun ignores_percentages() { + assertNull(parse("Get 50% off on every £ purchase", "GBP")) + } + + @Test + fun ignores_numbers_without_currency_evidence() { + assertNull(parse("Your order 123456 has shipped")) + } + + @Test + fun ignores_numbers_far_from_currency_evidence() { + assertNull(parse("Order 998877 confirmed, see your £ summary later in the week", "GBP")) + } + + @Test + fun ignores_zero_amounts() { + assertNull(parse("Authorization for £0.00 at MERCHANT", "GBP")) + } + + @Test + fun keeps_negative_sign_as_spend() { + assertEquals(BigDecimal("8.50"), parse("Card debit -£8.50 METRO", "GBP")) + } + + @Test + fun ignores_iso_code_embedded_in_a_longer_word() { + assertNull(parse("You spent 12.00 USDX at the shop")) + assertNull(parse("Swapped 12.00 BUSD on the exchange")) + } + + @Test + fun accepts_iso_code_glued_to_the_amount() { + assertEquals(BigDecimal("12.00"), parse("Compra de 12.00USD aprobada")) + } + + @Test + fun shared_symbol_alone_is_not_evidence() { + assertNull(parse("Purchase approved $42.90 at STARBUCKS")) + assertNull(parse("Compra aprobada $420.00 en OXXO", "MXN")) + } + + @Test + fun shared_symbol_with_iso_code_is_evidence() { + assertEquals(BigDecimal("42.90"), parse("Purchase approved $42.90 USD at STARBUCKS")) + assertEquals(BigDecimal("420.00"), parse("Compra MXN $420.00 en OXXO", "MXN")) + } +} diff --git a/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_BudgetPeriodSheetInteractionScreenshotTest_calculatedCardStaticHelper.png b/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_BudgetPeriodSheetInteractionScreenshotTest_calculatedCardStaticHelper.png index 0bad6cb2..15e38124 100644 Binary files a/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_BudgetPeriodSheetInteractionScreenshotTest_calculatedCardStaticHelper.png and b/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_BudgetPeriodSheetInteractionScreenshotTest_calculatedCardStaticHelper.png differ diff --git a/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_BudgetPillPaletteNightScreenshotTest_budgetPillPalette_dark.png b/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_BudgetPillPaletteNightScreenshotTest_budgetPillPalette_dark.png index ba4dd28a..e37f10c1 100644 Binary files a/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_BudgetPillPaletteNightScreenshotTest_budgetPillPalette_dark.png and b/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_BudgetPillPaletteNightScreenshotTest_budgetPillPalette_dark.png differ diff --git a/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_BudgetPillPaletteScreenshotTest_budgetPillPalette_light.png b/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_BudgetPillPaletteScreenshotTest_budgetPillPalette_light.png index cb72f25e..54162258 100644 Binary files a/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_BudgetPillPaletteScreenshotTest_budgetPillPalette_light.png and b/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_BudgetPillPaletteScreenshotTest_budgetPillPalette_light.png differ diff --git a/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_BudgetPillPaletteScreenshotTest_budgetPillPalette_overriddenScale.png b/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_BudgetPillPaletteScreenshotTest_budgetPillPalette_overriddenScale.png index 5554d606..5a4e91a9 100644 Binary files a/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_BudgetPillPaletteScreenshotTest_budgetPillPalette_overriddenScale.png and b/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_BudgetPillPaletteScreenshotTest_budgetPillPalette_overriddenScale.png differ diff --git a/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_BudgetPillScreenshotTest_budgetPillDailyExceededWithProjection.png b/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_BudgetPillScreenshotTest_budgetPillDailyExceededWithProjection.png index e636ac77..9bacf1eb 100644 Binary files a/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_BudgetPillScreenshotTest_budgetPillDailyExceededWithProjection.png and b/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_BudgetPillScreenshotTest_budgetPillDailyExceededWithProjection.png differ diff --git a/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_BudgetPillScreenshotTest_budgetPillDraftPushesOverDailyAllocation.png b/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_BudgetPillScreenshotTest_budgetPillDraftPushesOverDailyAllocation.png index e636ac77..d08225fd 100644 Binary files a/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_BudgetPillScreenshotTest_budgetPillDraftPushesOverDailyAllocation.png and b/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_BudgetPillScreenshotTest_budgetPillDraftPushesOverDailyAllocation.png differ diff --git a/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_BudgetPillScreenshotTest_budgetPillHealthy.png b/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_BudgetPillScreenshotTest_budgetPillHealthy.png index a3480d05..5483563e 100644 Binary files a/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_BudgetPillScreenshotTest_budgetPillHealthy.png and b/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_BudgetPillScreenshotTest_budgetPillHealthy.png differ diff --git a/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_BudgetPillSpanishScreenshotTest_budgetPillBiweeklyKeepsAmountWhenDailyAndWeeklyExhausted.png b/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_BudgetPillSpanishScreenshotTest_budgetPillBiweeklyKeepsAmountWhenDailyAndWeeklyExhausted.png index 6000091e..346082dd 100644 Binary files a/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_BudgetPillSpanishScreenshotTest_budgetPillBiweeklyKeepsAmountWhenDailyAndWeeklyExhausted.png and b/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_BudgetPillSpanishScreenshotTest_budgetPillBiweeklyKeepsAmountWhenDailyAndWeeklyExhausted.png differ diff --git a/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_ComponentScreenshotTest_budgetPillHealthyBudget.png b/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_ComponentScreenshotTest_budgetPillHealthyBudget.png index c34ea410..90f8982c 100644 Binary files a/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_ComponentScreenshotTest_budgetPillHealthyBudget.png and b/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_ComponentScreenshotTest_budgetPillHealthyBudget.png differ diff --git a/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_EditorScreenshotTest_editorEditingAmountWithTagsAndComment.png b/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_EditorScreenshotTest_editorEditingAmountWithTagsAndComment.png index ba5e9e24..c83b7f65 100644 Binary files a/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_EditorScreenshotTest_editorEditingAmountWithTagsAndComment.png and b/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_EditorScreenshotTest_editorEditingAmountWithTagsAndComment.png differ diff --git a/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_EditorScreenshotTest_editorEditingCalculationExpression.png b/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_EditorScreenshotTest_editorEditingCalculationExpression.png index 611ae804..c45c6071 100644 Binary files a/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_EditorScreenshotTest_editorEditingCalculationExpression.png and b/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_EditorScreenshotTest_editorEditingCalculationExpression.png differ diff --git a/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_EditorScreenshotTest_editorEditingExtraNoteLabelsWithChips.png b/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_EditorScreenshotTest_editorEditingExtraNoteLabelsWithChips.png index e26e569d..4ac1ce87 100644 Binary files a/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_EditorScreenshotTest_editorEditingExtraNoteLabelsWithChips.png and b/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_EditorScreenshotTest_editorEditingExtraNoteLabelsWithChips.png differ diff --git a/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_EditorScreenshotTest_editorEditingWithExtraNoteInline.png b/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_EditorScreenshotTest_editorEditingWithExtraNoteInline.png index fb3ce197..dad422f8 100644 Binary files a/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_EditorScreenshotTest_editorEditingWithExtraNoteInline.png and b/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_EditorScreenshotTest_editorEditingWithExtraNoteInline.png differ diff --git a/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_EditorScreenshotTest_editorIdleWithHealthyBudget.png b/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_EditorScreenshotTest_editorIdleWithHealthyBudget.png index 6b26b34c..8982e03d 100644 Binary files a/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_EditorScreenshotTest_editorIdleWithHealthyBudget.png and b/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_EditorScreenshotTest_editorIdleWithHealthyBudget.png differ diff --git a/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_FeatureLabScreenshotTest_featureLabScreen.png b/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_FeatureLabScreenshotTest_featureLabScreen.png index 46488a58..eded66f8 100644 Binary files a/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_FeatureLabScreenshotTest_featureLabScreen.png and b/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_FeatureLabScreenshotTest_featureLabScreen.png differ diff --git a/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_FeatureLabScreenshotTest_featureLabScreen_darkTheme.png b/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_FeatureLabScreenshotTest_featureLabScreen_darkTheme.png index 1aeaf54a..58062bdd 100644 Binary files a/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_FeatureLabScreenshotTest_featureLabScreen_darkTheme.png and b/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_FeatureLabScreenshotTest_featureLabScreen_darkTheme.png differ diff --git a/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_MainScreenScreenshotTest_mainScreenPhoneEditingExpense.png b/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_MainScreenScreenshotTest_mainScreenPhoneEditingExpense.png index 4cfeca98..f0581679 100644 Binary files a/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_MainScreenScreenshotTest_mainScreenPhoneEditingExpense.png and b/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_MainScreenScreenshotTest_mainScreenPhoneEditingExpense.png differ diff --git a/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_MainScreenScreenshotTest_mainScreenPhoneIdle.png b/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_MainScreenScreenshotTest_mainScreenPhoneIdle.png index 03fe9e57..c5eff35e 100644 Binary files a/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_MainScreenScreenshotTest_mainScreenPhoneIdle.png and b/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_MainScreenScreenshotTest_mainScreenPhoneIdle.png differ diff --git a/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_SubscriptionsScreenshotTest_subscriptionsDueSoonAndUpcoming.png b/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_SubscriptionsScreenshotTest_subscriptionsDueSoonAndUpcoming.png index f73d75db..c58c764c 100644 Binary files a/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_SubscriptionsScreenshotTest_subscriptionsDueSoonAndUpcoming.png and b/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_SubscriptionsScreenshotTest_subscriptionsDueSoonAndUpcoming.png differ diff --git a/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_SubscriptionsScreenshotTest_subscriptionsDueSoonAndUpcoming_darkTheme.png b/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_SubscriptionsScreenshotTest_subscriptionsDueSoonAndUpcoming_darkTheme.png index 4fdd4ed2..5e0dcd4d 100644 Binary files a/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_SubscriptionsScreenshotTest_subscriptionsDueSoonAndUpcoming_darkTheme.png and b/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_SubscriptionsScreenshotTest_subscriptionsDueSoonAndUpcoming_darkTheme.png differ diff --git a/app/src/test/snapshots/videos/com.serranoie.app.minus.presentation.ui.screenshot_BudgetPeriodSheetInteractionScreenshotTest_editModeTransition.png b/app/src/test/snapshots/videos/com.serranoie.app.minus.presentation.ui.screenshot_BudgetPeriodSheetInteractionScreenshotTest_editModeTransition.png index c4c0c61e..db58890d 100644 Binary files a/app/src/test/snapshots/videos/com.serranoie.app.minus.presentation.ui.screenshot_BudgetPeriodSheetInteractionScreenshotTest_editModeTransition.png and b/app/src/test/snapshots/videos/com.serranoie.app.minus.presentation.ui.screenshot_BudgetPeriodSheetInteractionScreenshotTest_editModeTransition.png differ diff --git a/app/src/test/snapshots/videos/com.serranoie.app.minus.presentation.ui.screenshot_BudgetPeriodSheetInteractionScreenshotTest_periodSelectionAndSheetSwipe.png b/app/src/test/snapshots/videos/com.serranoie.app.minus.presentation.ui.screenshot_BudgetPeriodSheetInteractionScreenshotTest_periodSelectionAndSheetSwipe.png index b5fd62d8..0b2cc98b 100644 Binary files a/app/src/test/snapshots/videos/com.serranoie.app.minus.presentation.ui.screenshot_BudgetPeriodSheetInteractionScreenshotTest_periodSelectionAndSheetSwipe.png and b/app/src/test/snapshots/videos/com.serranoie.app.minus.presentation.ui.screenshot_BudgetPeriodSheetInteractionScreenshotTest_periodSelectionAndSheetSwipe.png differ diff --git a/crowdin.yml b/crowdin.yml index 8153e200..ceb21425 100644 --- a/crowdin.yml +++ b/crowdin.yml @@ -1,3 +1,6 @@ +project_id: "912303" +api_token_env: CROWDIN_PERSONAL_TOKEN + files: - source: /app/src/main/res/values/strings.xml translation: /app/src/main/res/values-%two_letters_code%/strings.xml