Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c972125fef | |||
| cd94721fc8 | |||
| 8cb9166f41 | |||
| 010ff8a0c8 | |||
| 5288c980d0 | |||
| 0aaf00988d | |||
| e69796535f |
131
CLAUDE.md
Normal file
131
CLAUDE.md
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
# Working with this EasyLauncher fork
|
||||||
|
|
||||||
|
Quick reference for agents working on this fork (Gitea: `jonas/EasyLauncher`,
|
||||||
|
installed on the Unihertz Titan 2 Elite as `app.easy.launcher`).
|
||||||
|
|
||||||
|
**PROJECT.md in ~/titan2-elite is the source of truth for the phone itself.**
|
||||||
|
|
||||||
|
## Golden rules
|
||||||
|
|
||||||
|
1. The installed app id is **`app.easy.launcher`** — NOT upstream's
|
||||||
|
`com.github.droidworksstudio.launcher`. Every fork build must keep this id.
|
||||||
|
2. Release APKs are signed with `~/android-keystores/easylauncher-release.jks`
|
||||||
|
(alias `easylauncher`, pass in `easylauncher-release.pass`). Never commit an
|
||||||
|
unsigned or differently-signed APK to `dist/`.
|
||||||
|
3. `adb install -r dist/...-Signed.apk` updates the phone in place (same
|
||||||
|
signature). A debug build cannot be installed over it (signature mismatch).
|
||||||
|
4. After a phone reboot the phone is CE-locked until the user enters the PIN;
|
||||||
|
app prefs/location are not visible to the app until then. Ask the user to
|
||||||
|
unlock instead of debugging it.
|
||||||
|
5. Never fetch weather without a real location (lat/lon 0,0 must be treated as
|
||||||
|
"no location").
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export JAVA_HOME=~/jdk21 # Gradle 8.13, compileSdk/targetSdk 36, minSdk 24
|
||||||
|
./gradlew :app:compileWithInternetReleaseKotlin :app:compileWithoutInternetReleaseKotlin --offline
|
||||||
|
./gradlew :app:assembleWithInternetRelease :app:assembleWithoutInternetRelease --offline
|
||||||
|
```
|
||||||
|
|
||||||
|
- Two flavors: `withInternet` (INTERNET + location permissions, "Easy Launcher")
|
||||||
|
and `withoutInternet`.
|
||||||
|
- Outputs: `app/build/outputs/apk/{withInternet,withoutInternet}/release/`.
|
||||||
|
- `weather.properties` (OpenWeatherMap key) is optional and absent here — the
|
||||||
|
OWM weather widget is dead without it. The home-screen current-weather
|
||||||
|
element uses the MET/Yr API instead (no key needed, see below).
|
||||||
|
|
||||||
|
## Release flow (see git history for the v0.3.x pattern)
|
||||||
|
|
||||||
|
1. Bump `versionCode`/`versionName` in `app/build.gradle.kts`
|
||||||
|
(currently 35 / 0.3.5).
|
||||||
|
2. Build both flavors, then sign each:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PASS=$(cat ~/android-keystores/easylauncher-release.pass)
|
||||||
|
~/android-sdk/build-tools/36.0.0/apksigner sign --v4-signing-enabled true \
|
||||||
|
--ks ~/android-keystores/easylauncher-release.jks --ks-key-alias easylauncher \
|
||||||
|
--ks-pass "pass:$PASS" --key-pass "pass:$PASS" \
|
||||||
|
--out dist/EasyLauncher-Internet-v0.3.5-Signed.apk \
|
||||||
|
app/build/outputs/apk/withInternet/release/app.easy.launcher_v0.3.5-Release.apk
|
||||||
|
# repeat for withoutInternet -> dist/EasyLauncher-v0.3.5-Signed.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
3. `apksigner verify --print-certs` should show the keystore SHA-256.
|
||||||
|
4. Commit as `release build vX.Y.Z` (includes the dist APKs + .idsig files),
|
||||||
|
annotated tag `vX.Y.Z`, push `main` + tag to `origin` (Gitea), create a
|
||||||
|
Gitea release with both APKs as assets.
|
||||||
|
|
||||||
|
## On-device testing (Titan 2 Elite via adb)
|
||||||
|
|
||||||
|
- Wake: `input keyevent KEYCODE_WAKEUP`; go home: `input keyevent KEYCODE_HOME`.
|
||||||
|
- Open the app drawer: `input keyevent KEYCODE_SPACE` (the physical-keyboard
|
||||||
|
"key press -> app list" trigger; requires MainActivity focused).
|
||||||
|
- Scroll the drawer from the right edge: `input swipe 1000 850 1000 250 250`.
|
||||||
|
- Screenshot: `adb exec-out screencap -p > s.png`; view text via
|
||||||
|
`uiautomator dump`.
|
||||||
|
- Inspect the launcher's own state with `su -c '...'` (root via Magisk).
|
||||||
|
|
||||||
|
### Editing app prefs from the host (fragile — read carefully)
|
||||||
|
|
||||||
|
Prefs live in `/data/data/app.easy.launcher/shared_prefs/`. To change them:
|
||||||
|
|
||||||
|
1. Pull the file, edit locally, push back via stdin:
|
||||||
|
`adb shell "su -c 'cat > <path>'" < localfile`
|
||||||
|
2. **The file name must end in `.xml`** (`EasyLauncher.pref.xml`,
|
||||||
|
`EasyWeather.pref.xml`) — `getSharedPreferences("name")` appends `.xml`.
|
||||||
|
A missing suffix silently reads as an EMPTY map.
|
||||||
|
3. **Run `restorecon -F <file>`** — files created via `su cat` get the wrong
|
||||||
|
SELinux context (`s0` instead of `s0:c18,c257,c512,c768`) and the app can't
|
||||||
|
read them (silent empty map).
|
||||||
|
4. `am force-stop app.easy.launcher` then press HOME so the process restarts
|
||||||
|
and re-reads the file.
|
||||||
|
|
||||||
|
### Notification dots
|
||||||
|
|
||||||
|
- Service: `.service.NotificationBadgeService` (NotificationListenerService).
|
||||||
|
Users must grant notification access; the "Notification Dots" settings toggle
|
||||||
|
opens `ACTION_NOTIFICATION_LISTENER_SETTINGS` when missing.
|
||||||
|
- Grant from adb:
|
||||||
|
`cmd notification allow_listener app.easy.launcher/com.github.droidworksstudio.launcher.service.NotificationBadgeService`
|
||||||
|
- Test notification: `cmd notification post -t 'Title' tag 'body'` — posts as
|
||||||
|
`com.android.shell`, which has no launcher icon, so no dot is visible; verify
|
||||||
|
visually with a real app notification instead.
|
||||||
|
- Counts live in `NotificationBadgeService.notificationCounts`
|
||||||
|
(StateFlow, key `"userId/packageName"`); fragments rebind visible rows.
|
||||||
|
|
||||||
|
### Home current-weather element (MET/Yr)
|
||||||
|
|
||||||
|
- Source: `https://api.met.no/weatherapi/locationforecast/2.0/compact` — **no API
|
||||||
|
key**, but REQUIRES a descriptive `User-Agent` header (see `MetApiService`).
|
||||||
|
Verified working from PC and phone network.
|
||||||
|
- `AppHelper.fetchMetWeather(context, lat, lon)` returns Celsius (rounded) +
|
||||||
|
MET `symbol_code`; 15-minute cache in `met_weather_prefs`.
|
||||||
|
- The icon is a **Nerd Font weather glyph** (`nf-weather-*`, U+E300+ PUA),
|
||||||
|
mapped from the symbol code in `MetSymbolMapper`. It renders with the bundled
|
||||||
|
`R.font.jetbrains_mono_nf_weather` — a ~95KB fontTools subset of
|
||||||
|
JetBrainsMonoNerdFont (ASCII + 38 weather glyphs + °). The phone's system
|
||||||
|
font IS `JetBrainsMonoNerdFont` (fonts.xml default), but the bundled subset
|
||||||
|
guarantees rendering even if the launcher-font setting changes. Regenerate
|
||||||
|
the subset with fontTools if more glyphs are ever needed (source:
|
||||||
|
`/system/fonts/JetBrainsMonoNerdFont-Regular.ttf`).
|
||||||
|
- Display mirrors the daily word (uses the daily-word color/size/alignment
|
||||||
|
prefs); text = `"$glyph $temp°"` (two spaces).
|
||||||
|
- Needs a real saved location: `EasyWeather.pref.xml` keys `LATITUDE`/
|
||||||
|
`LONGITUDE` (floats). MainActivity saves the real fix there; for testing you
|
||||||
|
can seed e.g. Haugesund 59.4138 / 5.2680 (remember the `.xml` suffix +
|
||||||
|
`restorecon`!).
|
||||||
|
|
||||||
|
## Known behavior quirks
|
||||||
|
|
||||||
|
- The accessibility-service dialog ("Please turn on accessibility service to
|
||||||
|
use double tap to lock") pops on home when `ActionService` isn't running.
|
||||||
|
Enable the service in system settings to suppress it during testing.
|
||||||
|
- `Application.setCustomFont` patches the `Typeface` DEFAULT/MONO/SERIF/SANS
|
||||||
|
static fields when a non-System launcher font is selected.
|
||||||
|
- With "Disable Animations" on, the drawer RecyclerView item animator is null
|
||||||
|
and navigation transitions are skipped.
|
||||||
|
- Drawer whole-screen scrolling: `appListTouchArea` forwards vertical drags/
|
||||||
|
flings through `OnSwipeTouchListener` hooks (`onVerticalScroll`/`onVerticalFling`).
|
||||||
|
Signs were verified against AOSP source: `scrollBy(0, distanceY)` is
|
||||||
|
pass-through; fling takes the NEGATED pointer velocity.
|
||||||
@@ -19,8 +19,8 @@ android {
|
|||||||
applicationId = "app.easy.launcher"
|
applicationId = "app.easy.launcher"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 34
|
versionCode = 35
|
||||||
versionName = "0.3.4"
|
versionName = "0.3.5"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
manifestPlaceholders["internetPermission"] = "android.permission.INTERNET"
|
manifestPlaceholders["internetPermission"] = "android.permission.INTERNET"
|
||||||
|
|||||||
@@ -103,6 +103,15 @@
|
|||||||
<action android:name="android.accessibilityservice.AccessibilityService" />
|
<action android:name="android.accessibilityservice.AccessibilityService" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
|
<service
|
||||||
|
android:name=".service.NotificationBadgeService"
|
||||||
|
android:exported="true"
|
||||||
|
android:label="@string/notification_badge_service_label"
|
||||||
|
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.service.notification.NotificationListenerService" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="${applicationId}.provider"
|
android:authorities="${applicationId}.provider"
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1. This license is available with a FAQ at: https://scripts.sil.org/OFL
|
||||||
@@ -33,14 +33,18 @@ import com.github.droidworksstudio.launcher.R
|
|||||||
import com.github.droidworksstudio.launcher.accessibility.ActionService
|
import com.github.droidworksstudio.launcher.accessibility.ActionService
|
||||||
import com.github.droidworksstudio.launcher.data.dao.AppInfoDAO
|
import com.github.droidworksstudio.launcher.data.dao.AppInfoDAO
|
||||||
import com.github.droidworksstudio.launcher.data.entities.AppInfo
|
import com.github.droidworksstudio.launcher.data.entities.AppInfo
|
||||||
|
import com.github.droidworksstudio.launcher.helper.weather.MetForecastResponse
|
||||||
import com.github.droidworksstudio.launcher.helper.weather.WeatherResponse
|
import com.github.droidworksstudio.launcher.helper.weather.WeatherResponse
|
||||||
import com.github.droidworksstudio.launcher.utils.Constants
|
import com.github.droidworksstudio.launcher.utils.Constants
|
||||||
|
import com.github.droidworksstudio.launcher.utils.MetApiService
|
||||||
|
import com.github.droidworksstudio.launcher.utils.MetSymbolMapper
|
||||||
import com.github.droidworksstudio.launcher.utils.WeatherApiService
|
import com.github.droidworksstudio.launcher.utils.WeatherApiService
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import com.google.gson.JsonSyntaxException
|
import com.google.gson.JsonSyntaxException
|
||||||
import com.google.gson.reflect.TypeToken
|
import com.google.gson.reflect.TypeToken
|
||||||
import com.google.gson.stream.JsonReader
|
import com.google.gson.stream.JsonReader
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlin.math.roundToInt
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import retrofit2.converter.gson.GsonConverterFactory
|
import retrofit2.converter.gson.GsonConverterFactory
|
||||||
import java.net.UnknownHostException
|
import java.net.UnknownHostException
|
||||||
@@ -264,7 +268,9 @@ class AppHelper @Inject constructor() {
|
|||||||
if (nextAlarmClock == null) return "No alarm is set."
|
if (nextAlarmClock == null) return "No alarm is set."
|
||||||
|
|
||||||
val alarmTime = nextAlarmClock.triggerTime
|
val alarmTime = nextAlarmClock.triggerTime
|
||||||
val formattedTime = SimpleDateFormat("EEE, MMM d hh:mm a", Locale.getDefault()).format(alarmTime)
|
val is24Hour = android.text.format.DateFormat.is24HourFormat(context)
|
||||||
|
val timePattern = if (is24Hour) "EEE, MMM d HH:mm" else "EEE, MMM d hh:mm a"
|
||||||
|
val formattedTime = SimpleDateFormat(timePattern, Locale.getDefault()).format(alarmTime)
|
||||||
|
|
||||||
val drawable = AppCompatResources.getDrawable(context, R.drawable.ic_alarm_clock)
|
val drawable = AppCompatResources.getDrawable(context, R.drawable.ic_alarm_clock)
|
||||||
val fontSize = TypedValue.applyDimension(
|
val fontSize = TypedValue.applyDimension(
|
||||||
@@ -550,4 +556,91 @@ class AppHelper @Inject constructor() {
|
|||||||
|
|
||||||
// Data class to hold cached weather data along with timestamp
|
// Data class to hold cached weather data along with timestamp
|
||||||
data class CachedWeatherData(val timestamp: Long, val weatherResponse: WeatherResponse)
|
data class CachedWeatherData(val timestamp: Long, val weatherResponse: WeatherResponse)
|
||||||
|
|
||||||
|
sealed class MetWeatherResult {
|
||||||
|
data class Success(val temperature: Int, val symbolCode: String) : MetWeatherResult()
|
||||||
|
data class Failure(val errorMessage: String) : MetWeatherResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the current temperature and symbol from the MET/Yr API
|
||||||
|
* (api.met.no locationforecast). No API key needed - only a
|
||||||
|
* descriptive User-Agent header. Result is cached for 15 minutes.
|
||||||
|
*/
|
||||||
|
fun fetchMetWeather(
|
||||||
|
context: Context,
|
||||||
|
latitude: Float,
|
||||||
|
longitude: Float,
|
||||||
|
): MetWeatherResult {
|
||||||
|
if (latitude == 0f && longitude == 0f) {
|
||||||
|
return MetWeatherResult.Failure("No location available")
|
||||||
|
}
|
||||||
|
|
||||||
|
val cached = context.getMetWeatherFromCache()
|
||||||
|
if (cached?.let {
|
||||||
|
System.currentTimeMillis() - it.first < TimeUnit.MINUTES.toMillis(15)
|
||||||
|
} == true
|
||||||
|
) {
|
||||||
|
return cached.second
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val retrofit = Retrofit.Builder()
|
||||||
|
.baseUrl("https://api.met.no/weatherapi/locationforecast/2.0/")
|
||||||
|
.addConverterFactory(GsonConverterFactory.create())
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val service = retrofit.create(MetApiService::class.java)
|
||||||
|
val response = service.getCompact(latitude.toDouble(), longitude.toDouble()).execute()
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
val body = response.body()
|
||||||
|
val timeseries = body?.properties?.timeseries
|
||||||
|
val first = timeseries?.firstOrNull()
|
||||||
|
if (first != null) {
|
||||||
|
val temperature = first.data.instant.details.airTemperature.roundToInt()
|
||||||
|
val symbolCode = first.data.next1Hours?.summary?.symbolCode
|
||||||
|
?: first.data.next6Hours?.summary?.symbolCode
|
||||||
|
?: "cloudy"
|
||||||
|
val result = MetWeatherResult.Success(temperature, symbolCode)
|
||||||
|
context.cacheMetWeather(result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
return MetWeatherResult.Failure("Empty forecast")
|
||||||
|
} else {
|
||||||
|
return MetWeatherResult.Failure("MET API error: ${response.code()}")
|
||||||
|
}
|
||||||
|
} catch (e: UnknownHostException) {
|
||||||
|
Log.e("AppHelper", "MET fetch: unknown host api.met.no", e)
|
||||||
|
return MetWeatherResult.Failure("Unknown host: api.met.no")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("AppHelper", "MET fetch failed", e)
|
||||||
|
return MetWeatherResult.Failure(e.message ?: "MET fetch failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the home-screen weather text: nerd-font weather glyph followed
|
||||||
|
* by the temperature in Celsius, e.g. "\uE312 16°".
|
||||||
|
*/
|
||||||
|
fun buildCurrentWeatherText(temperature: Int, symbolCode: String): String {
|
||||||
|
return "${MetSymbolMapper.glyphFor(symbolCode)} $temperature°"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Context.cacheMetWeather(result: MetWeatherResult.Success) {
|
||||||
|
val sharedPreferences = getSharedPreferences(Constants.MET_WEATHER_PREFS, Context.MODE_PRIVATE)
|
||||||
|
sharedPreferences.edit()
|
||||||
|
.putLong(Constants.MET_WEATHER_TIMESTAMP, System.currentTimeMillis())
|
||||||
|
.putInt(Constants.MET_WEATHER_TEMPERATURE, result.temperature)
|
||||||
|
.putString(Constants.MET_WEATHER_SYMBOL, result.symbolCode)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Context.getMetWeatherFromCache(): Pair<Long, MetWeatherResult>? {
|
||||||
|
val sharedPreferences = getSharedPreferences(Constants.MET_WEATHER_PREFS, Context.MODE_PRIVATE)
|
||||||
|
val timestamp = sharedPreferences.getLong(Constants.MET_WEATHER_TIMESTAMP, -1L)
|
||||||
|
if (timestamp == -1L) return null
|
||||||
|
val temperature = sharedPreferences.getInt(Constants.MET_WEATHER_TEMPERATURE, 0)
|
||||||
|
val symbol = sharedPreferences.getString(Constants.MET_WEATHER_SYMBOL, "cloudy") ?: "cloudy"
|
||||||
|
return timestamp to MetWeatherResult.Success(temperature, symbol)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,6 +198,14 @@ class PreferenceHelper @Inject constructor(@ApplicationContext context: Context)
|
|||||||
get() = prefs.getBoolean(Constants.DISABLE_ANIMATIONS, false)
|
get() = prefs.getBoolean(Constants.DISABLE_ANIMATIONS, false)
|
||||||
set(value) = prefs.edit().putBoolean(Constants.DISABLE_ANIMATIONS, value).apply()
|
set(value) = prefs.edit().putBoolean(Constants.DISABLE_ANIMATIONS, value).apply()
|
||||||
|
|
||||||
|
var showNotificationDots: Boolean
|
||||||
|
get() = prefs.getBoolean(Constants.SHOW_NOTIFICATION_DOTS, false)
|
||||||
|
set(value) = prefs.edit().putBoolean(Constants.SHOW_NOTIFICATION_DOTS, value).apply()
|
||||||
|
|
||||||
|
var showCurrentWeather: Boolean
|
||||||
|
get() = prefs.getBoolean(Constants.SHOW_CURRENT_WEATHER, false)
|
||||||
|
set(value) = prefs.edit().putBoolean(Constants.SHOW_CURRENT_WEATHER, value).apply()
|
||||||
|
|
||||||
var searchEngines: Constants.SearchEngines
|
var searchEngines: Constants.SearchEngines
|
||||||
get() {
|
get() {
|
||||||
return try {
|
return try {
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.github.droidworksstudio.launcher.helper.weather
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locationforecast 2.0 compact response (api.met.no) - only the fields the
|
||||||
|
* home-screen current-weather element needs.
|
||||||
|
*/
|
||||||
|
data class MetForecastResponse(
|
||||||
|
val properties: MetProperties
|
||||||
|
)
|
||||||
|
|
||||||
|
data class MetProperties(
|
||||||
|
val timeseries: List<MetTimeSeries>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class MetTimeSeries(
|
||||||
|
val time: String,
|
||||||
|
val data: MetData
|
||||||
|
)
|
||||||
|
|
||||||
|
data class MetData(
|
||||||
|
val instant: MetInstant,
|
||||||
|
@SerializedName("next_1_hours")
|
||||||
|
val next1Hours: MetNextHours? = null,
|
||||||
|
@SerializedName("next_6_hours")
|
||||||
|
val next6Hours: MetNextHours? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class MetInstant(
|
||||||
|
val details: MetDetails
|
||||||
|
)
|
||||||
|
|
||||||
|
data class MetDetails(
|
||||||
|
@SerializedName("air_temperature")
|
||||||
|
val airTemperature: Double = 0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
data class MetNextHours(
|
||||||
|
val summary: MetSummary
|
||||||
|
)
|
||||||
|
|
||||||
|
data class MetSummary(
|
||||||
|
@SerializedName("symbol_code")
|
||||||
|
val symbolCode: String = ""
|
||||||
|
)
|
||||||
@@ -90,6 +90,7 @@ internal open class OnSwipeTouchListener(context: Context?, private val preferen
|
|||||||
}
|
}
|
||||||
// Vertical swipe
|
// Vertical swipe
|
||||||
else {
|
else {
|
||||||
|
onVerticalScroll(distanceY)
|
||||||
if (abs(diffY) > swipeThreshold && abs(distanceY) > swipeScrollThreshold) {
|
if (abs(diffY) > swipeThreshold && abs(distanceY) > swipeScrollThreshold) {
|
||||||
if (diffY > 0) onSwipeDown() else onSwipeUp()
|
if (diffY > 0) onSwipeDown() else onSwipeUp()
|
||||||
}
|
}
|
||||||
@@ -115,6 +116,7 @@ internal open class OnSwipeTouchListener(context: Context?, private val preferen
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (abs(diffY) > swipeThreshold && abs(velocityY) > swipeVelocityThreshold) {
|
if (abs(diffY) > swipeThreshold && abs(velocityY) > swipeVelocityThreshold) {
|
||||||
|
onVerticalFling(velocityY)
|
||||||
if (diffY < 0) onSwipeUp() else onSwipeDown()
|
if (diffY < 0) onSwipeUp() else onSwipeDown()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,6 +131,21 @@ internal open class OnSwipeTouchListener(context: Context?, private val preferen
|
|||||||
open fun onSwipeLeft() {}
|
open fun onSwipeLeft() {}
|
||||||
open fun onSwipeUp() {}
|
open fun onSwipeUp() {}
|
||||||
open fun onSwipeDown() {}
|
open fun onSwipeDown() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called for every vertical drag movement (per-event delta, same sign
|
||||||
|
* convention as RecyclerView's own touch handling: positive when the
|
||||||
|
* finger moves up). Default is a no-op.
|
||||||
|
*/
|
||||||
|
open fun onVerticalScroll(distanceY: Float) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called on a vertical fling with the raw pointer velocity in pixels per
|
||||||
|
* second (positive when the finger moved down, same convention as
|
||||||
|
* VelocityTracker.getYVelocity()). Default is a no-op.
|
||||||
|
*/
|
||||||
|
open fun onVerticalFling(velocityY: Float) {}
|
||||||
|
|
||||||
open fun onLongClick() {}
|
open fun onLongClick() {}
|
||||||
open fun onDoubleClick() {}
|
open fun onDoubleClick() {}
|
||||||
open fun onTripleClick() {}
|
open fun onTripleClick() {}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package com.github.droidworksstudio.launcher.service
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.service.notification.NotificationListenerService
|
||||||
|
import android.service.notification.StatusBarNotification
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks pending (dismissible) notifications per app so the launcher can show
|
||||||
|
* a red dot on app icons.
|
||||||
|
*
|
||||||
|
* The user must grant notification access in the system settings
|
||||||
|
* (Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS) before this service
|
||||||
|
* receives any events. Android rebinds it automatically after every boot.
|
||||||
|
*/
|
||||||
|
class NotificationBadgeService : NotificationListenerService() {
|
||||||
|
|
||||||
|
/** Notification key -> "userId/packageName". Keyed to dedupe updates. */
|
||||||
|
private val keyToPackage = HashMap<String, String>()
|
||||||
|
|
||||||
|
override fun onListenerConnected() {
|
||||||
|
super.onListenerConnected()
|
||||||
|
synchronized(keyToPackage) {
|
||||||
|
keyToPackage.clear()
|
||||||
|
activeNotifications.forEach { sbn ->
|
||||||
|
if (isCounted(sbn)) keyToPackage[sbn.key] = userIdKey(sbn)
|
||||||
|
}
|
||||||
|
publish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNotificationPosted(sbn: StatusBarNotification) {
|
||||||
|
if (!isCounted(sbn)) return
|
||||||
|
synchronized(keyToPackage) {
|
||||||
|
// A posted notification with an existing key is just an update.
|
||||||
|
if (keyToPackage.containsKey(sbn.key)) return
|
||||||
|
keyToPackage[sbn.key] = userIdKey(sbn)
|
||||||
|
publish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNotificationRemoved(sbn: StatusBarNotification) {
|
||||||
|
synchronized(keyToPackage) {
|
||||||
|
if (keyToPackage.remove(sbn.key) != null) publish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only swipe-away, non-ongoing notifications get a dot. */
|
||||||
|
private fun isCounted(sbn: StatusBarNotification): Boolean {
|
||||||
|
val notification = sbn.notification
|
||||||
|
if (!sbn.isClearable) return false
|
||||||
|
if (notification.flags and Notification.FLAG_ONGOING_EVENT != 0) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun userIdKey(sbn: StatusBarNotification) =
|
||||||
|
"${sbn.userId}/" + sbn.packageName
|
||||||
|
|
||||||
|
private fun publish() {
|
||||||
|
val counts = HashMap<String, Int>()
|
||||||
|
keyToPackage.values.forEach { key ->
|
||||||
|
counts[key] = (counts[key] ?: 0) + 1
|
||||||
|
}
|
||||||
|
notificationCounts.value = counts
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Active notification count per app, keyed by "userId/packageName". */
|
||||||
|
val notificationCounts: MutableStateFlow<Map<String, Int>> =
|
||||||
|
MutableStateFlow(emptyMap())
|
||||||
|
|
||||||
|
fun hasNotifications(userId: Int, packageName: String): Boolean =
|
||||||
|
notificationCounts.value["$userId/$packageName"]?.let { it > 0 } ?: false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,8 @@ import com.github.droidworksstudio.launcher.helper.PreferenceHelper
|
|||||||
import com.github.droidworksstudio.launcher.listener.OnItemClickedListener
|
import com.github.droidworksstudio.launcher.listener.OnItemClickedListener
|
||||||
import com.github.droidworksstudio.launcher.listener.OnSwipeTouchListener
|
import com.github.droidworksstudio.launcher.listener.OnSwipeTouchListener
|
||||||
import com.github.droidworksstudio.launcher.listener.ScrollEventListener
|
import com.github.droidworksstudio.launcher.listener.ScrollEventListener
|
||||||
|
import com.github.droidworksstudio.launcher.service.NotificationBadgeService
|
||||||
|
import kotlin.math.roundToInt
|
||||||
import com.github.droidworksstudio.launcher.ui.bottomsheetdialog.AppInfoBottomSheetFragment
|
import com.github.droidworksstudio.launcher.ui.bottomsheetdialog.AppInfoBottomSheetFragment
|
||||||
import com.github.droidworksstudio.launcher.utils.Constants
|
import com.github.droidworksstudio.launcher.utils.Constants
|
||||||
import com.github.droidworksstudio.launcher.viewmodel.AppViewModel
|
import com.github.droidworksstudio.launcher.viewmodel.AppViewModel
|
||||||
@@ -110,6 +112,7 @@ class DrawFragment : Fragment(),
|
|||||||
observeClickListener()
|
observeClickListener()
|
||||||
observeSwipeTouchListener()
|
observeSwipeTouchListener()
|
||||||
observeScrollTouchListener()
|
observeScrollTouchListener()
|
||||||
|
observeNotificationBadges()
|
||||||
|
|
||||||
// Initialize observation of drawer apps
|
// Initialize observation of drawer apps
|
||||||
observeDrawerApps()
|
observeDrawerApps()
|
||||||
@@ -157,6 +160,30 @@ class DrawFragment : Fragment(),
|
|||||||
setHasFixedSize(false)
|
setHasFixedSize(false)
|
||||||
layoutManager = StaggeredGridLayoutManager(1, StaggeredGridLayoutManager.VERTICAL)
|
layoutManager = StaggeredGridLayoutManager(1, StaggeredGridLayoutManager.VERTICAL)
|
||||||
isNestedScrollingEnabled = false
|
isNestedScrollingEnabled = false
|
||||||
|
|
||||||
|
// Search results update the list with DiffUtil. When animations
|
||||||
|
// are disabled, drop the item animator so entries don't "pan-up"
|
||||||
|
// into place while filtering.
|
||||||
|
if (preferenceHelper.disableAnimations) itemAnimator = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeNotificationBadges() {
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
repeatOnLifecycle(Lifecycle.State.CREATED) {
|
||||||
|
NotificationBadgeService.notificationCounts.collect {
|
||||||
|
val recyclerView = binding.drawAdapter
|
||||||
|
for (i in 0 until recyclerView.childCount) {
|
||||||
|
val holder = recyclerView.getChildViewHolder(recyclerView.getChildAt(i))
|
||||||
|
if (holder is DrawViewHolder) {
|
||||||
|
val position = holder.bindingAdapterPosition
|
||||||
|
if (position != RecyclerView.NO_POSITION) {
|
||||||
|
holder.bind(drawAdapter.currentList[position])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,7 +264,7 @@ class DrawFragment : Fragment(),
|
|||||||
binding.apply {
|
binding.apply {
|
||||||
mainView.setOnTouchListener(getSwipeGestureListener(context))
|
mainView.setOnTouchListener(getSwipeGestureListener(context))
|
||||||
touchArea.setOnTouchListener(getSwipeGestureListener(context))
|
touchArea.setOnTouchListener(getSwipeGestureListener(context))
|
||||||
appListTouchArea.setOnTouchListener(getSwipeGestureListener(context))
|
appListTouchArea.setOnTouchListener(getAppListSwipeGestureListener(context))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,36 +329,65 @@ class DrawFragment : Fragment(),
|
|||||||
return object : OnSwipeTouchListener(context, preferenceHelper) {
|
return object : OnSwipeTouchListener(context, preferenceHelper) {
|
||||||
override fun onSwipeLeft() {
|
override fun onSwipeLeft() {
|
||||||
super.onSwipeLeft()
|
super.onSwipeLeft()
|
||||||
val actionTypeNavOptions: NavOptions? =
|
navigateToHome(Constants.Swipe.Left)
|
||||||
if (preferenceHelper.disableAnimations) null
|
|
||||||
else appHelper.getActionType(Constants.Swipe.Left)
|
|
||||||
|
|
||||||
Handler(Looper.getMainLooper()).post {
|
|
||||||
findNavController().navigate(
|
|
||||||
R.id.HomeFragment,
|
|
||||||
null,
|
|
||||||
actionTypeNavOptions
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSwipeRight() {
|
override fun onSwipeRight() {
|
||||||
super.onSwipeRight()
|
super.onSwipeRight()
|
||||||
val actionTypeNavOptions: NavOptions? =
|
navigateToHome(Constants.Swipe.Right)
|
||||||
if (preferenceHelper.disableAnimations) null
|
|
||||||
else appHelper.getActionType(Constants.Swipe.Right)
|
|
||||||
|
|
||||||
Handler(Looper.getMainLooper()).post {
|
|
||||||
findNavController().navigate(
|
|
||||||
R.id.HomeFragment,
|
|
||||||
null,
|
|
||||||
actionTypeNavOptions
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Swipe listener for the app list area.
|
||||||
|
*
|
||||||
|
* Behaves like [getSwipeGestureListener] (left/right swipes navigate
|
||||||
|
* home), but vertical drags and flings over the empty space around the
|
||||||
|
* list (e.g. right of the app names) scroll the list itself. This makes
|
||||||
|
* the drawer scrollable from the whole screen, not just the app rows.
|
||||||
|
*/
|
||||||
|
private fun getAppListSwipeGestureListener(context: Context): View.OnTouchListener {
|
||||||
|
return object : OnSwipeTouchListener(context, preferenceHelper) {
|
||||||
|
override fun onSwipeLeft() {
|
||||||
|
super.onSwipeLeft()
|
||||||
|
navigateToHome(Constants.Swipe.Left)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSwipeRight() {
|
||||||
|
super.onSwipeRight()
|
||||||
|
navigateToHome(Constants.Swipe.Right)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onVerticalScroll(distanceY: Float) {
|
||||||
|
super.onVerticalScroll(distanceY)
|
||||||
|
binding.drawAdapter.scrollBy(0, distanceY.roundToInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onVerticalFling(velocityY: Float) {
|
||||||
|
super.onVerticalFling(velocityY)
|
||||||
|
// RecyclerView.fling expects the negated pointer velocity
|
||||||
|
// (the same negation RecyclerView applies to its own
|
||||||
|
// VelocityTracker value in onTouchEvent).
|
||||||
|
binding.drawAdapter.fling(0, -velocityY.roundToInt())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun navigateToHome(swipe: Constants.Swipe) {
|
||||||
|
val actionTypeNavOptions: NavOptions? =
|
||||||
|
if (preferenceHelper.disableAnimations) null
|
||||||
|
else appHelper.getActionType(swipe)
|
||||||
|
|
||||||
|
Handler(Looper.getMainLooper()).post {
|
||||||
|
findNavController().navigate(
|
||||||
|
R.id.HomeFragment,
|
||||||
|
null,
|
||||||
|
actionTypeNavOptions
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs the fuzzy search synchronously over the in-memory index.
|
* Runs the fuzzy search synchronously over the in-memory index.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ import com.github.droidworksstudio.launcher.data.entities.AppInfo
|
|||||||
import com.github.droidworksstudio.launcher.databinding.ItemDrawBinding
|
import com.github.droidworksstudio.launcher.databinding.ItemDrawBinding
|
||||||
import com.github.droidworksstudio.launcher.helper.PreferenceHelper
|
import com.github.droidworksstudio.launcher.helper.PreferenceHelper
|
||||||
import com.github.droidworksstudio.launcher.listener.OnItemClickedListener
|
import com.github.droidworksstudio.launcher.listener.OnItemClickedListener
|
||||||
|
import com.github.droidworksstudio.launcher.service.NotificationBadgeService
|
||||||
import com.github.droidworksstudio.launcher.utils.Constants
|
import com.github.droidworksstudio.launcher.utils.Constants
|
||||||
|
import com.github.droidworksstudio.launcher.utils.NotificationDotHelper
|
||||||
|
|
||||||
class DrawViewHolder(
|
class DrawViewHolder(
|
||||||
private val binding: ItemDrawBinding,
|
private val binding: ItemDrawBinding,
|
||||||
@@ -70,7 +72,20 @@ class DrawViewHolder(
|
|||||||
}
|
}
|
||||||
|
|
||||||
appDrawIcon.layoutParams = layoutParams
|
appDrawIcon.layoutParams = layoutParams
|
||||||
appDrawIcon.setImageDrawable(appNewIcon ?: nonNullDrawable)
|
|
||||||
|
val baseIcon: Drawable = appNewIcon ?: nonNullDrawable
|
||||||
|
appDrawIcon.setImageDrawable(
|
||||||
|
if (preferenceHelper.showNotificationDots &&
|
||||||
|
NotificationBadgeService.hasNotifications(
|
||||||
|
appInfo.userHandle,
|
||||||
|
appInfo.packageName
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
NotificationDotHelper.withDot(itemView.context, baseIcon)
|
||||||
|
} else {
|
||||||
|
baseIcon
|
||||||
|
}
|
||||||
|
)
|
||||||
appDrawIcon.visibility = View.VISIBLE
|
appDrawIcon.visibility = View.VISIBLE
|
||||||
|
|
||||||
val parentLayout = appDrawName.parent as LinearLayoutCompat
|
val parentLayout = appDrawName.parent as LinearLayoutCompat
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import android.view.ViewGroup
|
|||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.viewModels
|
import androidx.fragment.app.viewModels
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
import androidx.navigation.fragment.findNavController
|
import androidx.navigation.fragment.findNavController
|
||||||
import androidx.recyclerview.widget.ItemTouchHelper
|
import androidx.recyclerview.widget.ItemTouchHelper
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
@@ -28,6 +30,8 @@ import com.github.droidworksstudio.launcher.helper.PreferenceHelper
|
|||||||
import com.github.droidworksstudio.launcher.listener.OnItemClickedListener
|
import com.github.droidworksstudio.launcher.listener.OnItemClickedListener
|
||||||
import com.github.droidworksstudio.launcher.listener.OnItemMoveListener
|
import com.github.droidworksstudio.launcher.listener.OnItemMoveListener
|
||||||
import com.github.droidworksstudio.launcher.listener.OnSwipeTouchListener
|
import com.github.droidworksstudio.launcher.listener.OnSwipeTouchListener
|
||||||
|
import com.github.droidworksstudio.launcher.service.NotificationBadgeService
|
||||||
|
import com.github.droidworksstudio.launcher.ui.favorite.FavoriteViewHolder
|
||||||
import com.github.droidworksstudio.launcher.viewmodel.AppViewModel
|
import com.github.droidworksstudio.launcher.viewmodel.AppViewModel
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -82,6 +86,7 @@ class FavoriteFragment : Fragment(),
|
|||||||
setupRecyclerView()
|
setupRecyclerView()
|
||||||
observeFavorite()
|
observeFavorite()
|
||||||
observeHomeAppOrder()
|
observeHomeAppOrder()
|
||||||
|
observeNotificationBadges()
|
||||||
observeSwipeTouchListener()
|
observeSwipeTouchListener()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,6 +133,25 @@ class FavoriteFragment : Fragment(),
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun observeNotificationBadges() {
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
repeatOnLifecycle(Lifecycle.State.CREATED) {
|
||||||
|
NotificationBadgeService.notificationCounts.collect {
|
||||||
|
val recyclerView = binding.favoriteAdapter
|
||||||
|
for (i in 0 until recyclerView.childCount) {
|
||||||
|
val holder = recyclerView.getChildViewHolder(recyclerView.getChildAt(i))
|
||||||
|
if (holder is FavoriteViewHolder) {
|
||||||
|
val position = holder.bindingAdapterPosition
|
||||||
|
if (position != RecyclerView.NO_POSITION) {
|
||||||
|
holder.bind(favoriteAdapter.currentList[position])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun observeHomeAppOrder() {
|
private fun observeHomeAppOrder() {
|
||||||
binding.favoriteAdapter.adapter = favoriteAdapter
|
binding.favoriteAdapter.adapter = favoriteAdapter
|
||||||
val listener: OnItemMoveListener.OnItemActionListener = favoriteAdapter
|
val listener: OnItemMoveListener.OnItemActionListener = favoriteAdapter
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import com.github.droidworksstudio.launcher.data.entities.AppInfo
|
|||||||
import com.github.droidworksstudio.launcher.databinding.ItemFavoriteBinding
|
import com.github.droidworksstudio.launcher.databinding.ItemFavoriteBinding
|
||||||
import com.github.droidworksstudio.launcher.helper.PreferenceHelper
|
import com.github.droidworksstudio.launcher.helper.PreferenceHelper
|
||||||
import com.github.droidworksstudio.launcher.listener.OnItemClickedListener
|
import com.github.droidworksstudio.launcher.listener.OnItemClickedListener
|
||||||
|
import com.github.droidworksstudio.launcher.service.NotificationBadgeService
|
||||||
|
import com.github.droidworksstudio.launcher.utils.NotificationDotHelper
|
||||||
|
|
||||||
@SuppressLint("ClickableViewAccessibility")
|
@SuppressLint("ClickableViewAccessibility")
|
||||||
class FavoriteViewHolder(
|
class FavoriteViewHolder(
|
||||||
@@ -48,7 +50,18 @@ class FavoriteViewHolder(
|
|||||||
if (preferenceHelper.showAppIcon) {
|
if (preferenceHelper.showAppIcon) {
|
||||||
val appIcon =
|
val appIcon =
|
||||||
binding.root.context.packageManager.getApplicationIcon(appInfo.packageName)
|
binding.root.context.packageManager.getApplicationIcon(appInfo.packageName)
|
||||||
appFavoriteLeftIcon.setImageDrawable(appIcon)
|
val baseIcon: android.graphics.drawable.Drawable =
|
||||||
|
if (preferenceHelper.showNotificationDots &&
|
||||||
|
NotificationBadgeService.hasNotifications(
|
||||||
|
appInfo.userHandle,
|
||||||
|
appInfo.packageName
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
NotificationDotHelper.withDot(binding.root.context, appIcon)
|
||||||
|
} else {
|
||||||
|
appIcon
|
||||||
|
}
|
||||||
|
appFavoriteLeftIcon.setImageDrawable(baseIcon)
|
||||||
appFavoriteLeftIcon.layoutParams.width =
|
appFavoriteLeftIcon.layoutParams.width =
|
||||||
preferenceHelper.appTextSize.toInt() * 3
|
preferenceHelper.appTextSize.toInt() * 3
|
||||||
appFavoriteLeftIcon.layoutParams.height =
|
appFavoriteLeftIcon.layoutParams.height =
|
||||||
|
|||||||
@@ -22,9 +22,12 @@ import androidx.annotation.RequiresApi
|
|||||||
import androidx.appcompat.widget.AppCompatTextView
|
import androidx.appcompat.widget.AppCompatTextView
|
||||||
import androidx.biometric.BiometricPrompt
|
import androidx.biometric.BiometricPrompt
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.content.res.ResourcesCompat
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.viewModels
|
import androidx.fragment.app.viewModels
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
import androidx.navigation.NavOptions
|
import androidx.navigation.NavOptions
|
||||||
import androidx.navigation.fragment.findNavController
|
import androidx.navigation.fragment.findNavController
|
||||||
import androidx.recyclerview.widget.StaggeredGridLayoutManager
|
import androidx.recyclerview.widget.StaggeredGridLayoutManager
|
||||||
@@ -46,6 +49,7 @@ import com.github.droidworksstudio.launcher.helper.PreferenceHelper
|
|||||||
import com.github.droidworksstudio.launcher.listener.OnItemClickedListener
|
import com.github.droidworksstudio.launcher.listener.OnItemClickedListener
|
||||||
import com.github.droidworksstudio.launcher.listener.OnSwipeTouchListener
|
import com.github.droidworksstudio.launcher.listener.OnSwipeTouchListener
|
||||||
import com.github.droidworksstudio.launcher.listener.ScrollEventListener
|
import com.github.droidworksstudio.launcher.listener.ScrollEventListener
|
||||||
|
import com.github.droidworksstudio.launcher.service.NotificationBadgeService
|
||||||
import com.github.droidworksstudio.launcher.ui.bottomsheetdialog.AppInfoBottomSheetFragment
|
import com.github.droidworksstudio.launcher.ui.bottomsheetdialog.AppInfoBottomSheetFragment
|
||||||
import com.github.droidworksstudio.launcher.utils.Constants
|
import com.github.droidworksstudio.launcher.utils.Constants
|
||||||
import com.github.droidworksstudio.launcher.viewmodel.AppViewModel
|
import com.github.droidworksstudio.launcher.viewmodel.AppViewModel
|
||||||
@@ -54,6 +58,7 @@ import dagger.hilt.android.AndroidEntryPoint
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
|
||||||
@@ -111,6 +116,12 @@ class HomeFragment : Fragment(),
|
|||||||
setupRecyclerView()
|
setupRecyclerView()
|
||||||
observeSwipeTouchListener()
|
observeSwipeTouchListener()
|
||||||
observeUserInterfaceSettings()
|
observeUserInterfaceSettings()
|
||||||
|
|
||||||
|
// Nerd-font weather glyphs (nf-weather-*) live in the bundled
|
||||||
|
// subset font; the system font here is JetBrainsMonoNerdFont but
|
||||||
|
// this guarantees rendering even if the launcher font is changed.
|
||||||
|
binding.currentWeather.typeface =
|
||||||
|
ResourcesCompat.getFont(requireContext(), R.font.jetbrains_mono_nf_weather)
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("ClickableViewAccessibility")
|
@SuppressLint("ClickableViewAccessibility")
|
||||||
@@ -232,6 +243,25 @@ class HomeFragment : Fragment(),
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun observeNotificationBadges() {
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
repeatOnLifecycle(Lifecycle.State.CREATED) {
|
||||||
|
NotificationBadgeService.notificationCounts.collect {
|
||||||
|
val recyclerView = binding.appListAdapter
|
||||||
|
for (i in 0 until recyclerView.childCount) {
|
||||||
|
val holder = recyclerView.getChildViewHolder(recyclerView.getChildAt(i))
|
||||||
|
if (holder is HomeViewHolder) {
|
||||||
|
val position = holder.bindingAdapterPosition
|
||||||
|
if (position != androidx.recyclerview.widget.RecyclerView.NO_POSITION) {
|
||||||
|
holder.bind(homeAdapter.currentList[position])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@SuppressLint("ClickableViewAccessibility", "InflateParams")
|
@SuppressLint("ClickableViewAccessibility", "InflateParams")
|
||||||
private fun observeSwipeTouchListener() {
|
private fun observeSwipeTouchListener() {
|
||||||
binding.apply {
|
binding.apply {
|
||||||
@@ -250,6 +280,7 @@ class HomeFragment : Fragment(),
|
|||||||
preferenceViewModel.setShowDate(preferenceHelper.showDate)
|
preferenceViewModel.setShowDate(preferenceHelper.showDate)
|
||||||
preferenceViewModel.setShowAlarmClock(preferenceHelper.showAlarmClock)
|
preferenceViewModel.setShowAlarmClock(preferenceHelper.showAlarmClock)
|
||||||
preferenceViewModel.setShowDailyWord(preferenceHelper.showDailyWord)
|
preferenceViewModel.setShowDailyWord(preferenceHelper.showDailyWord)
|
||||||
|
preferenceViewModel.setShowCurrentWeather(preferenceHelper.showCurrentWeather)
|
||||||
preferenceViewModel.setShowBattery(preferenceHelper.showBattery)
|
preferenceViewModel.setShowBattery(preferenceHelper.showBattery)
|
||||||
|
|
||||||
preferenceViewModel.showTimeLiveData.observe(viewLifecycleOwner) {
|
preferenceViewModel.showTimeLiveData.observe(viewLifecycleOwner) {
|
||||||
@@ -301,6 +332,17 @@ class HomeFragment : Fragment(),
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
preferenceViewModel.showCurrentWeatherLiveData.observe(viewLifecycleOwner) {
|
||||||
|
appHelper.updateUI(
|
||||||
|
binding.currentWeather,
|
||||||
|
preferenceHelper.homeDailyWordAlignment,
|
||||||
|
preferenceHelper.dailyWordColor,
|
||||||
|
preferenceHelper.dailyWordTextSize,
|
||||||
|
it
|
||||||
|
)
|
||||||
|
if (it) loadCurrentWeather()
|
||||||
|
}
|
||||||
|
|
||||||
binding.apply {
|
binding.apply {
|
||||||
mainView.hideKeyboard()
|
mainView.hideKeyboard()
|
||||||
|
|
||||||
@@ -321,6 +363,41 @@ class HomeFragment : Fragment(),
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the current temperature from the MET/Yr API and shows it as a
|
||||||
|
* nerd-font weather glyph plus Celsius value, e.g. "\uE312 16°".
|
||||||
|
* Formatted like the daily word (same alignment/color/size settings).
|
||||||
|
*/
|
||||||
|
private fun loadCurrentWeather() {
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
|
||||||
|
val sharedPreferences =
|
||||||
|
requireContext().getSharedPreferences(Constants.WEATHER_PREFS, Context.MODE_PRIVATE)
|
||||||
|
val latitude = sharedPreferences.getFloat(Constants.LATITUDE, 0f)
|
||||||
|
val longitude = sharedPreferences.getFloat(Constants.LONGITUDE, 0f)
|
||||||
|
val result = appHelper.fetchMetWeather(requireContext(), latitude, longitude)
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
when (result) {
|
||||||
|
is AppHelper.MetWeatherResult.Success -> {
|
||||||
|
binding.currentWeather.text = appHelper.buildCurrentWeatherText(
|
||||||
|
result.temperature,
|
||||||
|
result.symbolCode
|
||||||
|
)
|
||||||
|
binding.currentWeather.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
|
||||||
|
is AppHelper.MetWeatherResult.Failure -> {
|
||||||
|
// No data (offline, no location): keep any cached text,
|
||||||
|
// otherwise hide the element.
|
||||||
|
if (binding.currentWeather.text.isNullOrEmpty()) {
|
||||||
|
binding.currentWeather.visibility = View.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun observeBioAuthCheck(appInfo: AppInfo) {
|
private fun observeBioAuthCheck(appInfo: AppInfo) {
|
||||||
if (!appInfo.lock)
|
if (!appInfo.lock)
|
||||||
context.launchApp(appInfo)
|
context.launchApp(appInfo)
|
||||||
@@ -584,6 +661,8 @@ class HomeFragment : Fragment(),
|
|||||||
binding.mainView.hideKeyboard()
|
binding.mainView.hideKeyboard()
|
||||||
observeUserInterfaceSettings()
|
observeUserInterfaceSettings()
|
||||||
observeFavoriteAppList()
|
observeFavoriteAppList()
|
||||||
|
observeNotificationBadges()
|
||||||
|
if (preferenceHelper.showCurrentWeather) loadCurrentWeather()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onAppClicked(appInfo: AppInfo) {
|
override fun onAppClicked(appInfo: AppInfo) {
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ import com.github.droidworksstudio.launcher.data.entities.AppInfo
|
|||||||
import com.github.droidworksstudio.launcher.databinding.ItemHomeBinding
|
import com.github.droidworksstudio.launcher.databinding.ItemHomeBinding
|
||||||
import com.github.droidworksstudio.launcher.helper.PreferenceHelper
|
import com.github.droidworksstudio.launcher.helper.PreferenceHelper
|
||||||
import com.github.droidworksstudio.launcher.listener.OnItemClickedListener
|
import com.github.droidworksstudio.launcher.listener.OnItemClickedListener
|
||||||
|
import com.github.droidworksstudio.launcher.service.NotificationBadgeService
|
||||||
import com.github.droidworksstudio.launcher.utils.Constants
|
import com.github.droidworksstudio.launcher.utils.Constants
|
||||||
|
import com.github.droidworksstudio.launcher.utils.NotificationDotHelper
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class HomeViewHolder @Inject constructor(
|
class HomeViewHolder @Inject constructor(
|
||||||
@@ -70,7 +72,20 @@ class HomeViewHolder @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
appHomeIcon.layoutParams = layoutParams
|
appHomeIcon.layoutParams = layoutParams
|
||||||
appHomeIcon.setImageDrawable(appNewIcon ?: nonNullDrawable)
|
|
||||||
|
val baseIcon: Drawable = appNewIcon ?: nonNullDrawable
|
||||||
|
appHomeIcon.setImageDrawable(
|
||||||
|
if (preferenceHelper.showNotificationDots &&
|
||||||
|
NotificationBadgeService.hasNotifications(
|
||||||
|
appInfo.userHandle,
|
||||||
|
appInfo.packageName
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
NotificationDotHelper.withDot(itemView.context, baseIcon)
|
||||||
|
} else {
|
||||||
|
baseIcon
|
||||||
|
}
|
||||||
|
)
|
||||||
appHomeIcon.visibility = View.VISIBLE
|
appHomeIcon.visibility = View.VISIBLE
|
||||||
|
|
||||||
val parentLayout = appHomeName.parent as LinearLayoutCompat
|
val parentLayout = appHomeName.parent as LinearLayoutCompat
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package com.github.droidworksstudio.launcher.ui.settings
|
package com.github.droidworksstudio.launcher.ui.settings
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Intent
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.provider.Settings
|
||||||
import android.view.Gravity
|
import android.view.Gravity
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
@@ -18,6 +21,7 @@ import androidx.lifecycle.lifecycleScope
|
|||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import androidx.navigation.fragment.findNavController
|
import androidx.navigation.fragment.findNavController
|
||||||
import com.github.droidworksstudio.common.getAppNameFromPackageName
|
import com.github.droidworksstudio.common.getAppNameFromPackageName
|
||||||
|
import com.github.droidworksstudio.common.showLongToast
|
||||||
import com.github.droidworksstudio.launcher.R
|
import com.github.droidworksstudio.launcher.R
|
||||||
import com.github.droidworksstudio.launcher.databinding.FragmentSettingsFeaturesBinding
|
import com.github.droidworksstudio.launcher.databinding.FragmentSettingsFeaturesBinding
|
||||||
import com.github.droidworksstudio.launcher.helper.AppHelper
|
import com.github.droidworksstudio.launcher.helper.AppHelper
|
||||||
@@ -25,6 +29,7 @@ import com.github.droidworksstudio.launcher.helper.AppReloader
|
|||||||
import com.github.droidworksstudio.launcher.helper.PreferenceHelper
|
import com.github.droidworksstudio.launcher.helper.PreferenceHelper
|
||||||
import com.github.droidworksstudio.launcher.listener.ScrollEventListener
|
import com.github.droidworksstudio.launcher.listener.ScrollEventListener
|
||||||
import com.github.droidworksstudio.launcher.repository.AppInfoRepository
|
import com.github.droidworksstudio.launcher.repository.AppInfoRepository
|
||||||
|
import com.github.droidworksstudio.launcher.service.NotificationBadgeService
|
||||||
import com.github.droidworksstudio.launcher.utils.Constants
|
import com.github.droidworksstudio.launcher.utils.Constants
|
||||||
import com.github.droidworksstudio.launcher.viewmodel.PreferenceViewModel
|
import com.github.droidworksstudio.launcher.viewmodel.PreferenceViewModel
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
@@ -129,6 +134,7 @@ class SettingsFeaturesFragment : Fragment(),
|
|||||||
homeAlignmentBottomSwitchCompat.isChecked = preferenceHelper.homeAlignmentBottom
|
homeAlignmentBottomSwitchCompat.isChecked = preferenceHelper.homeAlignmentBottom
|
||||||
lockSettingsSwitchCompat.isChecked = preferenceHelper.settingsLock
|
lockSettingsSwitchCompat.isChecked = preferenceHelper.settingsLock
|
||||||
disableAnimationsSwitchCompat.isChecked = preferenceHelper.disableAnimations
|
disableAnimationsSwitchCompat.isChecked = preferenceHelper.disableAnimations
|
||||||
|
showNotificationDotsSwitchCompat.isChecked = preferenceHelper.showNotificationDots
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,10 +456,37 @@ class SettingsFeaturesFragment : Fragment(),
|
|||||||
val feedbackType = if (isChecked) "on" else "off"
|
val feedbackType = if (isChecked) "on" else "off"
|
||||||
appHelper.triggerHapticFeedback(context, feedbackType)
|
appHelper.triggerHapticFeedback(context, feedbackType)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showNotificationDotsSwitchCompat.setOnCheckedChangeListener { _, isChecked ->
|
||||||
|
preferenceViewModel.setShowNotificationDots(isChecked)
|
||||||
|
val feedbackType = if (isChecked) "on" else "off"
|
||||||
|
appHelper.triggerHapticFeedback(context, feedbackType)
|
||||||
|
|
||||||
|
if (isChecked && !isNotificationAccessGranted()) {
|
||||||
|
openNotificationAccessSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun isNotificationAccessGranted(): Boolean {
|
||||||
|
val componentName = ComponentName(requireContext(), NotificationBadgeService::class.java)
|
||||||
|
val notificationManager =
|
||||||
|
requireContext().getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager
|
||||||
|
return notificationManager.isNotificationListenerAccessGranted(componentName)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openNotificationAccessSettings() {
|
||||||
|
try {
|
||||||
|
startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS))
|
||||||
|
} catch (_: Exception) {
|
||||||
|
requireContext().showLongToast(
|
||||||
|
getString(R.string.toast_cannot_open_notification_access_settings)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var swipeActionDialog: AlertDialog? = null
|
private var swipeActionDialog: AlertDialog? = null
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.Q)
|
@RequiresApi(Build.VERSION_CODES.Q)
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ class SettingsLookFeelFragment : Fragment(),
|
|||||||
alarmClockSwitchCompat.isChecked = preferenceHelper.showAlarmClock
|
alarmClockSwitchCompat.isChecked = preferenceHelper.showAlarmClock
|
||||||
dailyWordSwitchCompat.isChecked = preferenceHelper.showDailyWord
|
dailyWordSwitchCompat.isChecked = preferenceHelper.showDailyWord
|
||||||
appIconsSwitchCompat.isChecked = preferenceHelper.showAppIcon
|
appIconsSwitchCompat.isChecked = preferenceHelper.showAppIcon
|
||||||
|
currentWeatherSwitchCompat.isChecked = preferenceHelper.showCurrentWeather
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,6 +189,12 @@ class SettingsLookFeelFragment : Fragment(),
|
|||||||
val feedbackType = if (isChecked) "on" else "off"
|
val feedbackType = if (isChecked) "on" else "off"
|
||||||
appHelper.triggerHapticFeedback(context, feedbackType)
|
appHelper.triggerHapticFeedback(context, feedbackType)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
currentWeatherSwitchCompat.setOnCheckedChangeListener { _, isChecked ->
|
||||||
|
preferenceViewModel.setShowCurrentWeather(isChecked)
|
||||||
|
val feedbackType = if (isChecked) "on" else "off"
|
||||||
|
appHelper.triggerHapticFeedback(context, feedbackType)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ object Constants {
|
|||||||
const val WIDGET_BATTERY = "WIDGET_BATTERY"
|
const val WIDGET_BATTERY = "WIDGET_BATTERY"
|
||||||
|
|
||||||
const val WEATHER_PREFS = "EasyWeather.pref"
|
const val WEATHER_PREFS = "EasyWeather.pref"
|
||||||
|
const val MET_WEATHER_PREFS = "met_weather_prefs"
|
||||||
|
const val MET_WEATHER_TIMESTAMP = "MET_WEATHER_TIMESTAMP"
|
||||||
|
const val MET_WEATHER_TEMPERATURE = "MET_WEATHER_TEMPERATURE"
|
||||||
|
const val MET_WEATHER_SYMBOL = "MET_WEATHER_SYMBOL"
|
||||||
const val WEATHER_RESPONSE = "WEATHER_RESPONSE"
|
const val WEATHER_RESPONSE = "WEATHER_RESPONSE"
|
||||||
const val WEATHER_UNITS = "WEATHER_UNITS"
|
const val WEATHER_UNITS = "WEATHER_UNITS"
|
||||||
const val LATITUDE = "LATITUDE"
|
const val LATITUDE = "LATITUDE"
|
||||||
@@ -70,6 +74,8 @@ object Constants {
|
|||||||
const val HOME_ALLIGNMENT_BOTTOM = "HOME_ALLIGNMENT_BOTTOM"
|
const val HOME_ALLIGNMENT_BOTTOM = "HOME_ALLIGNMENT_BOTTOM"
|
||||||
const val TOGGLE_SETTING_LOCK = "TOGGLE_SETTING_LOCK"
|
const val TOGGLE_SETTING_LOCK = "TOGGLE_SETTING_LOCK"
|
||||||
const val DISABLE_ANIMATIONS = "DISABLE_ANIMATIONS"
|
const val DISABLE_ANIMATIONS = "DISABLE_ANIMATIONS"
|
||||||
|
const val SHOW_NOTIFICATION_DOTS = "SHOW_NOTIFICATION_DOTS"
|
||||||
|
const val SHOW_CURRENT_WEATHER = "SHOW_CURRENT_WEATHER"
|
||||||
|
|
||||||
const val HOME_DATE_ALIGNMENT = "HOME_DATE_ALIGNMENT"
|
const val HOME_DATE_ALIGNMENT = "HOME_DATE_ALIGNMENT"
|
||||||
const val HOME_TIME_ALIGNMENT = "HOME_TIME_ALIGNMENT"
|
const val HOME_TIME_ALIGNMENT = "HOME_TIME_ALIGNMENT"
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.github.droidworksstudio.launcher.utils
|
||||||
|
|
||||||
|
import com.github.droidworksstudio.launcher.helper.weather.MetForecastResponse
|
||||||
|
import retrofit2.Call
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.Headers
|
||||||
|
import retrofit2.http.Query
|
||||||
|
|
||||||
|
interface MetApiService {
|
||||||
|
|
||||||
|
@Headers(
|
||||||
|
"User-Agent: app.easy.launcher (https://gitea.haugesenspil.dk/jonas/EasyLauncher)"
|
||||||
|
)
|
||||||
|
@GET("compact")
|
||||||
|
fun getCompact(
|
||||||
|
@Query("lat") latitude: Double,
|
||||||
|
@Query("lon") longitude: Double
|
||||||
|
): Call<MetForecastResponse>
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package com.github.droidworksstudio.launcher.utils
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps MET/Yr symbol codes to Nerd Font weather glyphs
|
||||||
|
* (nf-weather-* codepoints from the Weather Icons font embedded in
|
||||||
|
* JetBrainsMonoNerdFont). Rendered with R.font.jetbrains_mono_nf_weather.
|
||||||
|
*/
|
||||||
|
object MetSymbolMapper {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the nf-weather glyph char for a MET `symbol_code`
|
||||||
|
* (e.g. "partlycloudy_day"). Unknown codes fall back to a cloud.
|
||||||
|
*/
|
||||||
|
fun glyphFor(symbolCode: String): Char {
|
||||||
|
val normalized = symbolCode.removeSuffix("_polartwilight")
|
||||||
|
val night = normalized.endsWith("_night")
|
||||||
|
val base = normalized
|
||||||
|
.removeSuffix("_day")
|
||||||
|
.removeSuffix("_night")
|
||||||
|
|
||||||
|
return when (base) {
|
||||||
|
"clearsky", "fair" -> if (night) '\uE32B' else '\uE30D'
|
||||||
|
"partlycloudy" -> if (night) '\uE379' else '\uE302'
|
||||||
|
"cloudy" -> '\uE312'
|
||||||
|
"fog" -> '\uE313'
|
||||||
|
"lightrain" -> '\uE31B'
|
||||||
|
"rain" -> '\uE318'
|
||||||
|
"heavyrain" -> '\uE319'
|
||||||
|
"lightrainshowers" -> if (night) '\uE328' else '\uE30B'
|
||||||
|
"rainshowers" -> if (night) '\uE326' else '\uE309'
|
||||||
|
"heavyrainshowers" -> if (night) '\uE324' else '\uE307'
|
||||||
|
"lightrainandthunder" -> '\uE315'
|
||||||
|
"rainandthunder" -> '\uE31D'
|
||||||
|
"heavyrainandthunder" -> '\uE31C'
|
||||||
|
"lightrainshowersandthunder" -> if (night) '\uE322' else '\uE305'
|
||||||
|
"rainshowersandthunder" -> if (night) '\uE32A' else '\uE30F'
|
||||||
|
"heavyrainshowersandthunder" -> if (night) '\uE329' else '\uE30E'
|
||||||
|
"sleet" -> '\uE316'
|
||||||
|
"lightssleetshowers", "sleetshowers" -> if (night) '\uE323' else '\uE306'
|
||||||
|
"heavysleetshowers" -> if (night) '\uE364' else '\uE362'
|
||||||
|
"sleetandthunder",
|
||||||
|
"lightsleetshowersandthunder",
|
||||||
|
"sleetshowersandthunder",
|
||||||
|
-> if (night) '\uE364' else '\uE362'
|
||||||
|
"lightssnow", "lightsnow", "snow" -> '\uE31A'
|
||||||
|
"heavysnow" -> '\uE35E'
|
||||||
|
"lightssnowshowers", "snowshowers" -> if (night) '\uE327' else '\uE30A'
|
||||||
|
"heavysnowshowers" -> if (night) '\uE361' else '\uE35F'
|
||||||
|
"snowandthunder",
|
||||||
|
"snowshowersandthunder",
|
||||||
|
-> if (night) '\uE367' else '\uE365'
|
||||||
|
"thunder" -> '\uE31D'
|
||||||
|
"wind" -> '\uE34B'
|
||||||
|
else -> '\uE312'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package com.github.droidworksstudio.launcher.utils
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.drawable.BitmapDrawable
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
|
import com.github.droidworksstudio.common.ColorIconsExtensions
|
||||||
|
|
||||||
|
object NotificationDotHelper {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a copy of [icon] with a red dot drawn in its top-right corner,
|
||||||
|
* surrounded by a thin white border so it stays visible on busy icons.
|
||||||
|
*/
|
||||||
|
fun withDot(context: Context, icon: Drawable): Drawable {
|
||||||
|
val source = ColorIconsExtensions.drawableToBitmap(icon)
|
||||||
|
val width = source.width
|
||||||
|
val height = source.height
|
||||||
|
if (width <= 0 || height <= 0) return icon
|
||||||
|
|
||||||
|
val dotRadius = (minOf(width, height) * 0.22f).coerceAtLeast(4f)
|
||||||
|
val centerX = width - dotRadius * 0.75f
|
||||||
|
val centerY = dotRadius * 0.75f
|
||||||
|
|
||||||
|
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||||
|
val canvas = Canvas(bitmap)
|
||||||
|
canvas.drawBitmap(source, 0f, 0f, null)
|
||||||
|
|
||||||
|
val dotPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
color = Color.RED
|
||||||
|
}
|
||||||
|
canvas.drawCircle(centerX, centerY, dotRadius, dotPaint)
|
||||||
|
|
||||||
|
val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
color = Color.WHITE
|
||||||
|
style = Paint.Style.STROKE
|
||||||
|
strokeWidth = (dotRadius * 0.28f).coerceAtLeast(1.5f)
|
||||||
|
}
|
||||||
|
canvas.drawCircle(
|
||||||
|
centerX,
|
||||||
|
centerY,
|
||||||
|
dotRadius - borderPaint.strokeWidth / 2f,
|
||||||
|
borderPaint
|
||||||
|
)
|
||||||
|
|
||||||
|
return BitmapDrawable(context.resources, bitmap)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,6 +49,8 @@ class PreferenceViewModel @Inject constructor(
|
|||||||
private val autoKeyboardLiveData: MutableLiveData<Boolean> = MutableLiveData()
|
private val autoKeyboardLiveData: MutableLiveData<Boolean> = MutableLiveData()
|
||||||
private val lockSettingsLiveData: MutableLiveData<Boolean> = MutableLiveData()
|
private val lockSettingsLiveData: MutableLiveData<Boolean> = MutableLiveData()
|
||||||
private val disableAnimationsLiveData: MutableLiveData<Boolean> = MutableLiveData()
|
private val disableAnimationsLiveData: MutableLiveData<Boolean> = MutableLiveData()
|
||||||
|
val showNotificationDotsLiveData: MutableLiveData<Boolean> = MutableLiveData()
|
||||||
|
val showCurrentWeatherLiveData: MutableLiveData<Boolean> = MutableLiveData()
|
||||||
private val appGroupPaddingSizeLiveData: MutableLiveData<Float> = MutableLiveData()
|
private val appGroupPaddingSizeLiveData: MutableLiveData<Float> = MutableLiveData()
|
||||||
private val appPaddingSizeLiveData: MutableLiveData<Float> = MutableLiveData()
|
private val appPaddingSizeLiveData: MutableLiveData<Float> = MutableLiveData()
|
||||||
|
|
||||||
@@ -297,6 +299,16 @@ class PreferenceViewModel @Inject constructor(
|
|||||||
disableAnimationsLiveData.postValue((preferenceHelper.disableAnimations))
|
disableAnimationsLiveData.postValue((preferenceHelper.disableAnimations))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setShowNotificationDots(showNotificationDots: Boolean) {
|
||||||
|
preferenceHelper.showNotificationDots = showNotificationDots
|
||||||
|
showNotificationDotsLiveData.postValue((preferenceHelper.showNotificationDots))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setShowCurrentWeather(showCurrentWeather: Boolean) {
|
||||||
|
preferenceHelper.showCurrentWeather = showCurrentWeather
|
||||||
|
showCurrentWeatherLiveData.postValue((preferenceHelper.showCurrentWeather))
|
||||||
|
}
|
||||||
|
|
||||||
fun setAppLanguage(appLanguage: Constants.Language) {
|
fun setAppLanguage(appLanguage: Constants.Language) {
|
||||||
preferenceHelper.appLanguage = appLanguage
|
preferenceHelper.appLanguage = appLanguage
|
||||||
appLanguageLiveData.postValue((preferenceHelper.appLanguage))
|
appLanguageLiveData.postValue((preferenceHelper.appLanguage))
|
||||||
|
|||||||
BIN
app/src/main/res/font/jetbrains_mono_nf_weather.ttf
Normal file
BIN
app/src/main/res/font/jetbrains_mono_nf_weather.ttf
Normal file
Binary file not shown.
@@ -78,6 +78,14 @@
|
|||||||
android:textSize="32sp"
|
android:textSize="32sp"
|
||||||
android:visibility="gone" />
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/currentWeather"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:textSize="32sp"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
</androidx.appcompat.widget.LinearLayoutCompat>
|
</androidx.appcompat.widget.LinearLayoutCompat>
|
||||||
|
|
||||||
<androidx.appcompat.widget.LinearLayoutCompat
|
<androidx.appcompat.widget.LinearLayoutCompat
|
||||||
|
|||||||
@@ -238,6 +238,37 @@
|
|||||||
tools:ignore="TouchTargetSizeCheck" />
|
tools:ignore="TouchTargetSizeCheck" />
|
||||||
|
|
||||||
</androidx.appcompat.widget.LinearLayoutCompat>
|
</androidx.appcompat.widget.LinearLayoutCompat>
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.LinearLayoutCompat
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
tools:ignore="MissingConstraints">
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.AppCompatTextView
|
||||||
|
android:id="@+id/showNotificationDots_text"
|
||||||
|
style="@style/TextDefaultStyle"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="left|center"
|
||||||
|
android:text="@string/settings_display_notification_dots"
|
||||||
|
android:textSize="@dimen/text_large"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
tools:ignore="RtlHardcoded" />
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.SwitchCompat
|
||||||
|
android:id="@+id/showNotificationDots_switchCompat"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:scaleX="0.7"
|
||||||
|
android:scaleY="0.8"
|
||||||
|
android:thumb="@drawable/shape_switch_thumb"
|
||||||
|
app:track="@drawable/selector_switch"
|
||||||
|
tools:ignore="TouchTargetSizeCheck" />
|
||||||
|
|
||||||
|
</androidx.appcompat.widget.LinearLayoutCompat>
|
||||||
</androidx.appcompat.widget.LinearLayoutCompat>
|
</androidx.appcompat.widget.LinearLayoutCompat>
|
||||||
|
|
||||||
<androidx.appcompat.widget.LinearLayoutCompat
|
<androidx.appcompat.widget.LinearLayoutCompat
|
||||||
|
|||||||
@@ -300,6 +300,37 @@
|
|||||||
tools:ignore="TouchTargetSizeCheck" />
|
tools:ignore="TouchTargetSizeCheck" />
|
||||||
|
|
||||||
</androidx.appcompat.widget.LinearLayoutCompat>
|
</androidx.appcompat.widget.LinearLayoutCompat>
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.LinearLayoutCompat
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
tools:ignore="MissingConstraints">
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.AppCompatTextView
|
||||||
|
android:id="@+id/currentWeather_text"
|
||||||
|
style="@style/TextDefaultStyle"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="left|center"
|
||||||
|
android:text="@string/settings_display_current_weather"
|
||||||
|
android:textSize="@dimen/text_large"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
tools:ignore="RtlHardcoded" />
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.SwitchCompat
|
||||||
|
android:id="@+id/currentWeather_switchCompat"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:scaleX="0.7"
|
||||||
|
android:scaleY="0.8"
|
||||||
|
android:thumb="@drawable/shape_switch_thumb"
|
||||||
|
app:track="@drawable/selector_switch"
|
||||||
|
tools:ignore="TouchTargetSizeCheck" />
|
||||||
|
|
||||||
|
</androidx.appcompat.widget.LinearLayoutCompat>
|
||||||
</androidx.appcompat.widget.LinearLayoutCompat>
|
</androidx.appcompat.widget.LinearLayoutCompat>
|
||||||
|
|
||||||
<androidx.appcompat.widget.LinearLayoutCompat
|
<androidx.appcompat.widget.LinearLayoutCompat
|
||||||
|
|||||||
@@ -104,12 +104,15 @@
|
|||||||
<string name="settings_display_alarm_clock">Show Alarm Clock</string>
|
<string name="settings_display_alarm_clock">Show Alarm Clock</string>
|
||||||
<string name="settings_display_daily_word">Show Daily Word</string>
|
<string name="settings_display_daily_word">Show Daily Word</string>
|
||||||
<string name="settings_display_app_icons">Show App Icons</string>
|
<string name="settings_display_app_icons">Show App Icons</string>
|
||||||
|
<string name="settings_display_current_weather">Show Current Weather</string>
|
||||||
<string name="settings_display_automatic_keyboard">Auto Show Keyboard</string>
|
<string name="settings_display_automatic_keyboard">Auto Show Keyboard</string>
|
||||||
<string name="settings_display_auto_open_apps">Auto Open Last App</string>
|
<string name="settings_display_auto_open_apps">Auto Open Last App</string>
|
||||||
<string name="settings_home_alignment_bottom">Home Alignment Bottom</string>
|
<string name="settings_home_alignment_bottom">Home Alignment Bottom</string>
|
||||||
<string name="settings_search_from_start">Search From Start</string>
|
<string name="settings_search_from_start">Search From Start</string>
|
||||||
<string name="settings_display_lock_settings">Lock Settings</string>
|
<string name="settings_display_lock_settings">Lock Settings</string>
|
||||||
<string name="settings_display_disable_animations">Disable Animations</string>
|
<string name="settings_display_disable_animations">Disable Animations</string>
|
||||||
|
<string name="settings_display_notification_dots">Notification Dots</string>
|
||||||
|
<string name="toast_cannot_open_notification_access_settings">Cannot open notification access settings.</string>
|
||||||
|
|
||||||
<string name="settings_appearance_text_size_title">Size</string>
|
<string name="settings_appearance_text_size_title">Size</string>
|
||||||
<string name="settings_appearance_color_title">Color</string>
|
<string name="settings_appearance_color_title">Color</string>
|
||||||
@@ -200,6 +203,7 @@
|
|||||||
<string name="accessibility_settings_disable">Disable</string>
|
<string name="accessibility_settings_disable">Disable</string>
|
||||||
|
|
||||||
<string name="accessibility_service_name">Easy Launcher Actions Service</string>
|
<string name="accessibility_service_name">Easy Launcher Actions Service</string>
|
||||||
|
<string name="notification_badge_service_label">Easy Launcher - notification dots</string>
|
||||||
<string name="accessibility_service_desc">Please turn on accessibility service to use double tap to lock feature in Easy Launcher.\n\nThis permission is used only to turn off your screen. Our
|
<string name="accessibility_service_desc">Please turn on accessibility service to use double tap to lock feature in Easy Launcher.\n\nThis permission is used only to turn off your screen. Our
|
||||||
accessibility service does not collect or share any data.</string>
|
accessibility service does not collect or share any data.</string>
|
||||||
|
|
||||||
|
|||||||
BIN
dist/EasyLauncher-Internet-v0.3.5-Signed.apk
vendored
Normal file
BIN
dist/EasyLauncher-Internet-v0.3.5-Signed.apk
vendored
Normal file
Binary file not shown.
BIN
dist/EasyLauncher-Internet-v0.3.5-Signed.apk.idsig
vendored
Normal file
BIN
dist/EasyLauncher-Internet-v0.3.5-Signed.apk.idsig
vendored
Normal file
Binary file not shown.
BIN
dist/EasyLauncher-v0.3.5-Signed.apk
vendored
Normal file
BIN
dist/EasyLauncher-v0.3.5-Signed.apk
vendored
Normal file
Binary file not shown.
BIN
dist/EasyLauncher-v0.3.5-Signed.apk.idsig
vendored
Normal file
BIN
dist/EasyLauncher-v0.3.5-Signed.apk.idsig
vendored
Normal file
Binary file not shown.
Reference in New Issue
Block a user