diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml
new file mode 100644
index 0000000..8c28231
--- /dev/null
+++ b/app/src/debug/AndroidManifest.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
diff --git a/app/src/main/graphql/schema.graphqls b/app/src/main/graphql/schema.graphqls
index 80fcaad..c464722 100644
--- a/app/src/main/graphql/schema.graphqls
+++ b/app/src/main/graphql/schema.graphqls
@@ -1,3 +1,164 @@
+"""
+A date-time string at UTC, such as 2007-12-03T10:15:30Z, compliant with the `date-time` format outlined in section 5.6 of the RFC 3339 profile of the ISO 8601 standard for representation of dates and times using the Gregorian calendar.
+"""
+scalar DateTime
+
+"""
+The `JSONObject` scalar type represents JSON objects as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf).
+"""
+scalar JSONObject
+
+"""
+Roles that can be held by a user. Used by @hasRole directive.
+"""
+enum Role {
+ visitor
+
+ appUser
+
+ apiUser
+
+ webUser
+
+ areaAdmin
+
+ admin
+}
+
+enum TimeFilter {
+ HOUR
+
+ DAY
+
+ WEEK
+
+ MONTH
+}
+
+enum Sensor {
+ DUST
+
+ TEMPERATURE
+
+ HUMIDITY
+
+ PRESSURE
+
+ RADIOACTIVITY
+
+ WATERLEVEL
+}
+
+enum MapLayer {
+ AQI
+
+ PM1
+
+ PM25
+
+ PM10
+
+ Temp
+
+ Hum
+
+ Press
+
+ Radio
+}
+
+type City {
+ _id: String
+
+ countryCode: String
+
+ cityName: CityName
+
+ areaId: String
+
+ areaName: String
+
+ latitude: Float
+
+ longitude: Float
+
+ locationCount: Int
+}
+
+"""
+The `Float` scalar type represents signed double-precision fractional values as specified by [IEEE 754](https://en.wikipedia.org/wiki/IEEE_floating_point).
+"""
+scalar Float
+
+"""
+The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1.
+"""
+scalar Int
+
+type CityName {
+ be: String
+
+ ru: String
+
+ en: String
+
+ ge: String
+
+ pl: String
+
+ alt: [String]
+}
+
+type Area {
+ _id: String
+
+ countryCode: String
+
+ areaName: AreaName
+}
+
+type AreaName {
+ be: String
+
+ ru: String
+
+ en: String
+}
+
+type LatLng {
+ lat: Float
+
+ lng: Float
+}
+
+type CalSession {
+ _id: String
+
+ deviceList: [String]
+
+ startedBy: String
+
+ refDevice: String
+
+ startTime: DateTime
+
+ stopTime: DateTime
+}
+
+type CalReport {
+ startTime: DateTime
+}
+
+input CalStartInput {
+ ids: [String]!
+
+ refDevice: String
+}
+
+input CalStopInput {
+ sessId: String!
+}
+
type Widget {
lName: String
@@ -9,9 +170,9 @@ type Widget {
lon: Float
- position: Int!
+ position: Int
- type: WidgetType!
+ type: WidgetType
whatToShow: String
@@ -28,21 +189,6 @@ type Widget {
active: Boolean
}
-"""
-The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text.
-"""
-scalar String
-
-"""
-The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1.
-"""
-scalar Int
-
-"""
-The `Float` scalar type represents signed double-precision fractional values as specified by [IEEE 754](https://en.wikipedia.org/wiki/IEEE_floating_point).
-"""
-scalar Float
-
"""
The `Boolean` scalar type represents `true` or `false`.
"""
@@ -90,15 +236,8 @@ input WidgetInput {
filter: TimeFilter
sensor: Sensor
-}
-"""
-ENUMs
-"""
-enum Upndown {
- UP
-
- DOWN
+ dpHeight: Int @deprecated(reason: "No longer used.")
}
enum WidgetType {
@@ -109,45 +248,16 @@ enum WidgetType {
NEWS
}
-type ActionResult {
- success: Boolean
+enum Upndown {
+ UP
- WiFiSSID: String
-
- WiFiPassword: String
-}
-
-type APIToken {
- token: String
-
- tokenName: String
-
- issDate: String
-
- expDate: String
-}
-
-type revokeResponse {
- success: Boolean
-}
-
-input newTokenInput {
- tokenName: String!
-
- validityDays: Int = 180
-}
-
-input oldTokenInput {
- tokenName: String
+ DOWN
}
type Device {
_id: String
- """
- model: Int
- """
- model: Model
+ model: DeviceModel
Sensorcom: Boolean
@@ -165,24 +275,28 @@ type Device {
isCalibrated: Boolean
- mfgDate: ISODate
+ mfgDate: DateTime
addedBy: String
- inventory: Inventory!
+ inventory: Inventory
+
+ hardware: HardwareInfo
calibration: Calibration
locationId: String
+ interval: Int
+
+ preferredGW: Int
+
status: Status
location: Location
}
type Inventory {
- HWVer: String
-
HTP: String
GC: String
@@ -191,6 +305,24 @@ type Inventory {
Gas: String
+ AUX: String
+
+ LVL: String
+
+ sensors: [SensorInfo]
+}
+
+type SensorInfo {
+ _id: String
+
+ type: String
+
+ name: String
+}
+
+type HardwareInfo {
+ HWVer: String
+
Ant: String
RTC: String
@@ -199,18 +331,18 @@ type Inventory {
LED: Boolean
- configVersion: String!
+ configVersion: String
firmwareVersion: String
hardwareVersion: String
+ hardwareType: String
+
secondaryWiFiSSID: String
}
type Calibration {
- VCC: CalObject
-
Temp: CalObject
Hum: CalObject
@@ -223,8 +355,20 @@ type Calibration {
PMS10: CalObject
+ CO2: CalObject
+
+ PPM: CalObject
+
+ VCC: CalObject
+
Count: CalObject
+ VOC: CalObject
+
+ NOx: CalObject
+
+ WaterLevel: CalObject
+
session: CalSession
report: CalReport
@@ -236,64 +380,28 @@ type CalObject {
B: Float
}
-type CalReport {
- startTime: ISODate
-}
-
-type CalSession {
- _id: String
-
- startTime: ISODate
-
- stopTime: ISODate
-
- deviceList: [String]
-
- startedBy: String
-
- refDevice: String
-}
-
-type Status {
- _id: String
-
- RSSI: Int
-
- build: String
-
- created: String
-
- ext_ip: String
-
- local_ip: String
-
- isOnline: Boolean
-
- lastOnline: ISODate
-
- maintenance: Boolean
-
- start: String
-
- stop: String
-
- uptime: Int
-
- LEDOn: Boolean
-}
-
-type Model {
+type DeviceModel {
_id: Int
name: String
PWRType: String
+ uplink: [String]
+
sensors: [String]
telemetry: [String]
}
+type ActionResult {
+ success: Boolean
+
+ WiFiSSID: String
+
+ WiFiPassword: String
+}
+
input DeviceFilter {
_id: String
@@ -362,16 +470,6 @@ input EditDevice {
Ant: String
}
-input CalStartInput {
- ids: [String]!
-
- refDevice: String
-}
-
-input CalStopInput {
- sessId: String!
-}
-
input NewCustomDevice {
id: String!
@@ -408,16 +506,20 @@ input DeviceInput {
testing: Boolean
inventory: InventoryInput
+
+ hardware: HardwareInput
}
input InventoryInput {
- HWVer: String
-
HTP: String
GC: String
PMS: String
+}
+
+input HardwareInput {
+ HWVer: String
Ant: String
@@ -425,21 +527,27 @@ input InventoryInput {
PWRType: String
+ LED: Boolean
+
configVersion: Int
firmwareVersion: String
+
+ hardwareVersion: String
+
+ hardwareType: String
}
input AddCal {
locationId: String!
- time: ISODate
+ time: DateTime
reset: Boolean
extAPI: Boolean
- metric: Metric
+ metric: CalMetric
value: Float
}
@@ -461,6 +569,8 @@ input SendAction {
OTAFlag: Boolean
+ immTx: Boolean
+
preferredGW: Int
interval: Int
@@ -472,187 +582,35 @@ input Creds {
WiFiPassword: String
}
-enum Sensor {
- DUST
-
- TEMPERATURE
-
- HUMIDITY
-
- PRESSURE
-
- RADIOACTIVITY
-}
-
-enum Metric {
+"""
+Metrics supported as manual calibration targets.
+"""
+enum CalMetric {
Temp
Hum
}
-"""
-A custom scalar that returns time data as JavaScript Date object
-"""
-scalar Date
-
-"""
-A custom scalar that returns data as a generic JavaScript Object. Data will be returned
-as a String, when data is published as a simple string.
-"""
-scalar Object
-
-scalar ISODate
-
-type Query {
- """
- deviceList: [Device] @hasScope(scopes: ["Device:view"])
- """
- deviceList: [Location]
-
- devices(filter: DeviceFilter): [Device]
-
- getUncalibratedDevices: [Device]
-
- listCalibrationSessions: [CalSession]
-
- locations(filter: LocationFilter): [Location]
-
- """
- locations(filter: LocationFilter): [Location] @hasRole(roles:[visitor, appUser, admin])
- users(filter: UserInput): [User] @hasScope(scopes: ["User:view"])
- """
- users(filter: UserInput): [User]
-
- """
- me: Me @hasScope(scopes: ["Me:view"])
- me: Me @hasRole(roles:[visitor, appUser, webUser, admin])
- """
- me: User
-
- widgetList(input: UserInput): [Widget]
-
- """
- metricList: [Metric]
- """
- cityList(langId: String): [City]
-
- sensorData(filter: LocationFilter): SensorData
-
- sensorDataRaw(filter: LocationFilter): SensorData
-
- cityAverageLast(filter: LastFilter): SensorData
-
- cityAverage(filter: MeanFilter): [SensorData]
-
- getPlace(filter: PlaceFilter): City
-
- getMarkers(filter: MarkerFilter): [Marker]
-
- checkSetupSuccess(deviceId: String): Device
-}
-
-type Mutation {
- sayHello(name: String!): String!
-
- initDevice(input: NewDevice): Device
-
- initDevices(input: NewDevices): [Device]
-
- editDevice(input: EditDevice): Device
-
- startCalibration(input: CalStartInput): CalSession
-
- stopCalibration(input: CalStopInput): CalSession
-
- setRefDevice(input: RefDevice): User
-
- addCustomDevice(input: NewCustomDevice): Device
-
- initLocation(input: NewLocation): Location
-
- addLocation(input: LocationInput): Location
-
- addFBLocation(input: LocationInput): Location
-
- addWebLocation(input: WebLocation): Location
-
- changeWebLocation(input: ChgLocation): Location
-
- changeLocation(input: ChgLocation): Location
-
- archiveLocation(input: inactivateLocation): Location
-
- """
- transferLocation(input: TransferLocation): Location @hasScope(scopes: ["Location:create"])
- """
- addUser(input: UserInput): User
-
- updateUser(input: UserInput): User
-
- setLastReadNews(_id: String): User
-
- addWidget(input: AddWidgetInput): [Widget]
-
- moveWidget(input: MoveWidgetInput): [Widget]
-
- removeWidget(input: RemoveWidgetInput): [Widget]
-
- authFacebook(input: AuthInput!): AuthResponse
-
- """
- authGoogle(input: AuthInput!): AuthResponse
- """
- authGoogle(input: AuthInput!): User
-
- authGoogleApp(input: AuthInput!): AuthResponse
-
- authGoogleWeb(input: AuthInput!): AuthResponse
-
- authGoogleNew(input: AuthInput!): AuthResponse
-
- authGoogleFB(input: AuthInput!): AuthResponse
-
- updateNotificationToken(token: String!): User
-
- generateAPIToken(input: newTokenInput): APIToken
-
- """
- revokeAPIToken(input: oldTokenInput): revokeResponse @hasScope(scopes: ["Device:create"])
- """
- revokeAPIToken: revokeResponse
-
- setCalibration(input: AddCal): Device
-
- setCal(input: AddCal): Device
-
- deviceAction(input: SendAction): ActionResult
-}
-
-type Subscription {
- onLocationAdded: Location
-
- onLocationChanged(input: LocInput): Location
-}
-
type Location {
_id: String!
- """
- locationId: String!
- """
- city: String!
+ city: String
name: String!
- deviceId: String!
+ deviceId: String
- initDate: ISODate!
+ initDate: DateTime
- isPublic: Boolean!
+ isPublic: Boolean
isPrivate: Boolean
- ownerId: String!
+ inactive: Boolean
+
+ inactivationDate: DateTime
+
+ ownerId: String
elevation: Int
@@ -660,19 +618,27 @@ type Location {
longitude: Float
- status: Status
-
Sensorcom: Boolean
Narodmon: Boolean
- device: Device!
+ isOnline: Boolean
+
+ status: Status
+
+ device: Device
user: User
- inactive: Boolean
+ metricList: [String]
- inactivationDate: ISODate
+ sensorList: [String]
+
+ timeSeries(filter: TimeSpan): [SensorData]
+
+ timeSeries_export(filter: TimeSpan): [SensorData]
+
+ currentValue(filter: TimeSpan): SensorData
sensorvalues_raw(input: TimeSpan): [SensorData]
@@ -692,39 +658,106 @@ type Location {
telemetry(input: TimeSpan): [Telemetry]
- sensorList: [String]
-
- metricList: [String]
-
externalWeatherData: SensorData
}
-type LatLng {
- lat: Float
-
- lng: Float
-}
-
-type City {
+type Status {
_id: String
- countryCode: String
+ RSSI: Int
- cityName: CityName
+ channel: Int
- locationCount: Int
+ build: String
- latitude: Float
+ created: String
- longitude: Float
+ ext_ip: String
+
+ local_ip: String
+
+ isOnline: Boolean
+
+ lastOnline: DateTime
+
+ maintenance: Boolean
+
+ LEDOn: Boolean
+
+ start: String
+
+ stop: String
+
+ uptime: Int
}
-type CityName {
- be: String
+"""
+Sensor reading row. All metric fields are optional; only those measured by the device are populated.
+"""
+type SensorData {
+ deviceId: String
- ru: String
+ time: DateTime
- en: String
+ Temp: Float
+
+ Temp1: Float
+
+ wTemp: Float
+
+ Hum: Float
+
+ Press: Float
+
+ PMS1: Float
+
+ PMS25: Float
+
+ PMS10: Float
+
+ Count: Float
+
+ radRg: Float
+
+ CO2: Float
+
+ PPM: Float
+
+ eCO2: Float
+
+ NOx: Float
+
+ VOC: Float
+
+ Windsp: Float
+
+ Windgs: Float
+
+ Windir: Float
+
+ Rain: Float
+
+ WaterLevel: Float
+
+ SolRad: Float
+
+ Light: Float
+
+ AQI: Int
+
+ IKAV: Float
+}
+
+type Telemetry {
+ deviceId: String
+
+ time: DateTime
+
+ VCC: Float
+
+ RSSI: Int
+
+ Uptime: Int
}
type Marker {
@@ -736,15 +769,14 @@ type Marker {
longitude: Float
- """
- status: Status
- """
text: String
isPublic: Boolean
owned: Boolean
+ metricList: [String]
+
value: Float
values: [SensorData]
@@ -753,6 +785,8 @@ type Marker {
input LocationFilter {
_id: String
+ _ids: [String]
+
locationId: String
deviceId: String
@@ -774,6 +808,62 @@ input LocationFilter {
device: DeviceInput
}
+input TimeSpan {
+ t_from: String
+
+ t_to: String
+
+ interval_d: Int = 0
+
+ interval_h: Int = 0
+
+ interval_m: Int = 0
+}
+
+input LastFilter {
+ city: String
+
+ cityName: String
+
+ cityId: String
+
+ interval_d: Int = 0
+
+ interval_h: Int = 0
+
+ interval_m: Int = 15
+}
+
+input MeanFilter {
+ city: String
+
+ cityName: String
+
+ cityId: String
+
+ t_from: String
+
+ t_to: String
+
+ interval_d: Int = 0
+
+ interval_h: Int = 0
+
+ interval_m: Int = 0
+}
+
+input MarkerFilter {
+ city: String
+
+ isOnline: Boolean
+
+ mapLayer: String
+}
+
+input LocInput {
+ _id: String
+}
+
input PlaceFilter {
latitude: Float
@@ -782,10 +872,6 @@ input PlaceFilter {
lang: String = "ru"
}
-input MarkerFilter {
- mapLayer: String
-}
-
input NewLocation {
deviceId: String!
@@ -794,21 +880,6 @@ input NewLocation {
ownerId: String!
}
-input RegLocation {
- deviceId: String!
-
- name: String
-
- """
- ownerId: String!
- """
- elevation: Int
-
- latitude: Float
-
- longitude: Float
-}
-
input WebLocation {
deviceId: String!
@@ -848,7 +919,7 @@ input LocationInput {
deviceId: String
- initDate: ISODate
+ initDate: DateTime
isPublic: Boolean
@@ -865,137 +936,16 @@ input LocationInput {
device: DeviceInput
}
-input LocInput {
- _id: String
-}
-
-enum MapLayers {
- AQI
-
- PM1
-
- PM25
-
- PM10
-
- Temp
-
- Hum
-
- Press
-
- Radio
-}
-
-type SensorData {
- deviceId: String
-
- time: ISODate
-
- Temp: Float
-
- Hum: Float
-
- Press: Float
-
- PMS1: Float
-
- PMS25: Float
-
- PMS10: Float
-
- PPM: Float
-
- eCO2: Float
-
- VOC: Float
-
- Count: Float
-
- AQI: Int
-
- IKAV: Float
-}
-
-type Telemetry {
- deviceId: String
-
- time: ISODate
-
- """
- IP: String
- """
- VCC: Float
-
- RSSI: Int
-
- Uptime: Int
-}
-
-input LastFilter {
- cityName: String
-
- cityId: String
-
- interval_d: Int = 0
-
- interval_h: Int = 0
-
- interval_m: Int = 15
-}
-
-input MeanFilter {
- cityName: String
-
- cityId: String
-
- t_from: String
-
- t_to: String
-
- interval_d: Int = 0
-
- interval_h: Int = 0
-
- interval_m: Int = 0
-}
-
-input TimeSpan {
- t_from: String
-
- t_to: String
-
- interval_d: Int = 0
-
- interval_h: Int = 0
-
- interval_m: Int = 0
-}
-
-enum TimeFilter {
- HOUR
-
- DAY
-
- WEEK
-
- MONTH
-}
-
type User {
_id: String
- uid: String @deprecated(reason: "Used no more")
-
email: String!
- lastReadNews: Int
-
name: String
locale: String
- regDate: ISODate
+ regDate: DateTime
authSource: String
@@ -1007,19 +957,23 @@ type User {
photoURL: String
- preferredCity: City
-
- APItokens: [APItoken]
-
notificationToken: String
+ lastReadNews: Int
+
+ emailVerified: Boolean
+
+ preferredCity: City
+
+ APItokens: [APIToken]
+
widgets: [Widget]
+ adminProperties: AdminProperties
+
locations: [Location]
devices: [Device]
-
- adminProperties: AdminProperties
}
type AdminProperties {
@@ -1033,7 +987,7 @@ type Me {
email: String
- expiryDate: ISODate
+ expiryDate: DateTime
apiRoles: [String]
@@ -1052,58 +1006,185 @@ type AuthResponse {
_id: String
regDate: String
+
+ emailVerified: Boolean
}
-type APItoken {
- issDate: ISODate
+type VerifyEmailResponse {
+ success: Boolean!
- expDate: ISODate
+ message: String
+}
- token: String
+type APIToken {
+ _id: String
tokenName: String
- _id: String
+ token: String
+
+ issDate: DateTime
+
+ expDate: DateTime
+}
+
+type RevokeResponse {
+ success: Boolean
}
input AuthInput {
accessToken: String!
}
+input LocalAuthInput {
+ email: String!
+
+ password: String!
+}
+
+input RegisterInput {
+ email: String!
+
+ password: String!
+
+ name: String
+}
+
input UserInput {
_id: String
- uid: String
-
email: String
- lastReadNews: Int
-
name: String
token: String
locale: String
+
+ lastReadNews: Int
}
input RefDevice {
_id: String
}
-enum Role {
- visitor
+input NewTokenInput {
+ tokenName: String!
- appUser
-
- apiUser
-
- webUser
-
- areaAdmin
-
- admin
+ validityDays: Int = 180
}
+type Query {
+ location(filter: LocationFilter): Location
+
+ locations(filter: LocationFilter): [Location]
+
+ cityList(langId: String, countryCode: String): [City]
+
+ getMarkers(filter: MarkerFilter): [Marker]
+
+ myLocation: Location
+
+ myLocations: [Location]
+
+ cityAverage(filter: MeanFilter): [SensorData]
+
+ cityAverageLast(filter: LastFilter): SensorData
+
+ getPlace(filter: PlaceFilter): City
+
+ me: User
+
+ widgetList(input: UserInput): [Widget]
+
+ deviceList: [Location]
+
+ devices(filter: DeviceFilter): [Device]
+
+ getUncalibratedDevices: [Device]
+
+ checkSetupSuccess(deviceId: String): Device
+
+ listCalibrationSessions: [CalSession]
+
+ users(filter: UserInput): [User]
+}
+
+type Mutation {
+ authGoogle(input: AuthInput!): AuthResponse
+
+ authGoogleApp(input: AuthInput!): AuthResponse
+
+ authGoogleWeb(input: AuthInput!): AuthResponse
+
+ loginLocal(input: LocalAuthInput!): AuthResponse
+
+ register(input: RegisterInput!): AuthResponse
+
+ verifyEmail(token: String!): VerifyEmailResponse
+
+ resendVerification(email: String!): Boolean
+
+ addUser(input: UserInput): User
+
+ updateUser(input: UserInput): User
+
+ setLastReadNews(_id: String): User
+
+ updateNotificationToken(token: String!): User
+
+ generateAPIToken(input: NewTokenInput): APIToken
+
+ revokeAPIToken: RevokeResponse
+
+ initLocation(input: NewLocation): Location
+
+ addLocation(input: LocationInput): Location
+
+ addWebLocation(input: WebLocation): Location
+
+ changeLocation(input: ChgLocation): Location
+
+ changeWebLocation(input: ChgLocation): Location
+
+ archiveLocation(input: inactivateLocation): Location
+
+ initDevice(input: NewDevice): Device
+
+ initDevices(input: NewDevices): [Device]
+
+ editDevice(input: EditDevice): Device
+
+ addCustomDevice(input: NewCustomDevice): Device
+
+ deviceAction(input: SendAction): ActionResult
+
+ setCalibration(input: AddCal): Device
+
+ startCalibration(input: CalStartInput): CalSession
+
+ stopCalibration(input: CalStopInput): CalSession
+
+ setRefDevice(input: RefDevice): User
+
+ addWidget(input: AddWidgetInput): [Widget]
+
+ moveWidget(input: MoveWidgetInput): [Widget]
+
+ removeWidget(input: RemoveWidgetInput): [Widget]
+}
+
+type Subscription {
+ onLocationAdded: Location
+
+ onLocationChanged(input: LocInput): Location
+}
+
+"""
+The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text.
+"""
+scalar String
+
"""
A GraphQL Schema defines the capabilities of a GraphQL server. It exposes all available types and directives on the server, as well as the entry points for query, mutation, and subscription operations.
"""
@@ -1139,7 +1220,7 @@ type __Schema {
"""
The fundamental unit of any GraphQL Schema is the type. There are many kinds of types in GraphQL as represented by the `__TypeKind` enum.
-Depending on the kind of a type, certain fields describe information about that type. Scalar types provide no information beyond a name, description and optional `specifiedByUrl`, while Enum types provide their values. Object and Interface types provide the fields they describe. Abstract types, Union and Interface, provide the Object types possible at runtime. List and NonNull types compose other types.
+Depending on the kind of a type, certain fields describe information about that type. Scalar types provide no information beyond a name, description and optional `specifiedByURL`, while Enum types provide their values. Object and Interface types provide the fields they describe. Abstract types, Union and Interface, provide the Object types possible at runtime. List and NonNull types compose other types.
"""
type __Type {
kind: __TypeKind!
@@ -1148,7 +1229,7 @@ type __Type {
description: String
- specifiedByUrl: String
+ specifiedByURL: String
fields(includeDeprecated: Boolean = false): [__Field!]
@@ -1161,6 +1242,8 @@ type __Type {
inputFields(includeDeprecated: Boolean = false): [__InputValue!]
ofType: __Type
+
+ isOneOf: Boolean
}
"""
@@ -1272,7 +1355,7 @@ type __Directive {
locations: [__DirectiveLocation!]!
- args: [__InputValue!]!
+ args(includeDeprecated: Boolean = false): [__InputValue!]!
}
"""
@@ -1375,30 +1458,11 @@ enum __DirectiveLocation {
INPUT_FIELD_DEFINITION
}
-directive @hasScope(scopes: [String]) on OBJECT | FIELD_DEFINITION
+directive @isAuthenticated on OBJECT | FIELD_DEFINITION
directive @hasRole(roles: [Role]) on OBJECT | FIELD_DEFINITION
-directive @isAuthenticated on OBJECT | FIELD_DEFINITION
-
-"""
-directive @isHidden on OBJECT | FIELD_DEFINITION | ENUM
-"""
-directive @undocumented on OBJECT | FIELD_DEFINITION | ENUM
-
-"""
-The directive instructs the API to implement the runtime administration behavior
-should the query or mutation support a particular administrative behavior,
-otherwise, it's ignored
-"""
-directive @isAdmin on FIELD
-
-"""
-The directive is applied at design time to fields that
-require that the caller has permissions to view data that
-is deemed to be PERSONAL in scope, for example email addresses.
-"""
-directive @requiresPersonalScope on FIELD_DEFINITION
+directive @hasScope(scopes: [String]) on OBJECT | FIELD_DEFINITION
"""
Directs the executor to include this field or fragment only when the `if` argument is true.
@@ -1416,9 +1480,14 @@ Marks an element of a GraphQL schema as no longer supported.
directive @deprecated("Explains why this element was deprecated, usually also including a suggestion for how to access supported similar data. Formatted using the Markdown syntax, as specified by [CommonMark](https://commonmark.org/)." reason: String = "No longer supported") on FIELD_DEFINITION | ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION | ENUM_VALUE
"""
-Exposes a URL that specifies the behaviour of this scalar.
+Exposes a URL that specifies the behavior of this scalar.
"""
-directive @specifiedBy("The URL that specifies the behaviour of this scalar." url: String!) on SCALAR
+directive @specifiedBy("The URL that specifies the behavior of this scalar." url: String!) on SCALAR
+
+"""
+Indicates exactly one field must be supplied and this field must not be `null`.
+"""
+directive @oneOf on INPUT_OBJECT
schema {
query: Query
diff --git a/app/src/main/kotlin/org/db3/airmq/features/dashboard/DashboardChartMapper.kt b/app/src/main/kotlin/org/db3/airmq/features/dashboard/DashboardChartMapper.kt
new file mode 100644
index 0000000..4962757
--- /dev/null
+++ b/app/src/main/kotlin/org/db3/airmq/features/dashboard/DashboardChartMapper.kt
@@ -0,0 +1,85 @@
+package org.db3.airmq.features.dashboard
+
+import org.db3.airmq.features.common.chart.ChartDataPoint
+import org.db3.airmq.features.common.chart.ChartDataset
+import org.db3.airmq.features.common.chart.ChartLine
+import org.db3.airmq.features.common.metric.SensorType
+import org.db3.airmq.sdk.dashboard.SensorSampleRow
+
+/**
+ * Maps city-average [SensorSampleRow] lists to [ChartDataset] and gauge values for the dashboard.
+ */
+internal object DashboardChartMapper {
+
+ /** Synthetic rows for Compose previews (no network). */
+ fun previewStaticRows(): List {
+ val now = System.currentTimeMillis()
+ return (0 until 12).map { i ->
+ val t = now - (12 - i) * 3_600_000L
+ SensorSampleRow(
+ epochMillis = t,
+ temp = 15f + i % 5,
+ hum = 50f + i,
+ press = 745f - i * 0.5f,
+ pms1 = 4f + i * 0.2f,
+ pms25 = 8f + i * 0.5f,
+ pms10 = 7f + i * 0.3f,
+ radRg = 0.12f,
+ ppm = null,
+ ikav = null,
+ )
+ }
+ }
+
+ fun chartDataset(rows: List, sensor: SensorType): ChartDataset {
+ val sorted = rows.sortedBy { it.epochMillis }
+ return when (sensor) {
+ SensorType.DUST -> {
+ val pm10 = sorted.mapNotNull { r ->
+ r.pms10?.let { ChartDataPoint(r.epochMillis, it) }
+ }
+ val pm25 = sorted.mapNotNull { r ->
+ r.pms25?.let { ChartDataPoint(r.epochMillis, it) }
+ }
+ val pm1 = sorted.mapNotNull { r ->
+ r.pms1?.let { ChartDataPoint(r.epochMillis, it) }
+ }
+ ChartDataset.Multi(
+ listOf(
+ ChartLine("PM10", pm10),
+ ChartLine("PM2.5", pm25),
+ ChartLine("PM1", pm1),
+ )
+ )
+ }
+ SensorType.TEMPERATURE -> ChartDataset.Single(
+ sorted.mapNotNull { r -> r.temp?.let { ChartDataPoint(r.epochMillis, it) } }
+ )
+ SensorType.HUMIDITY -> ChartDataset.Single(
+ sorted.mapNotNull { r -> r.hum?.let { ChartDataPoint(r.epochMillis, it) } }
+ )
+ SensorType.PRESSURE -> ChartDataset.Single(
+ sorted.mapNotNull { r -> r.press?.let { ChartDataPoint(r.epochMillis, it) } }
+ )
+ SensorType.RADIOACTIVITY -> ChartDataset.Single(
+ sorted.mapNotNull { r ->
+ (r.radRg ?: r.ppm ?: r.ikav)?.let { ChartDataPoint(r.epochMillis, it) }
+ }
+ )
+ SensorType.CO2, SensorType.VOC -> ChartDataset.Single(emptyList())
+ }
+ }
+
+ fun gaugeValues(last: SensorSampleRow?): Map {
+ if (last == null) return SensorType.entries.associateWith { null }
+ return mapOf(
+ SensorType.DUST to last.pms25,
+ SensorType.RADIOACTIVITY to (last.radRg ?: last.ppm ?: last.ikav),
+ SensorType.TEMPERATURE to last.temp,
+ SensorType.HUMIDITY to last.hum,
+ SensorType.PRESSURE to last.press,
+ SensorType.CO2 to null,
+ SensorType.VOC to null,
+ )
+ }
+}
diff --git a/app/src/main/kotlin/org/db3/airmq/features/dashboard/DashboardScreenContract.kt b/app/src/main/kotlin/org/db3/airmq/features/dashboard/DashboardScreenContract.kt
index d5ab220..5f7f2a8 100644
--- a/app/src/main/kotlin/org/db3/airmq/features/dashboard/DashboardScreenContract.kt
+++ b/app/src/main/kotlin/org/db3/airmq/features/dashboard/DashboardScreenContract.kt
@@ -5,10 +5,12 @@ import androidx.compose.ui.platform.LocalContext
import org.db3.airmq.R
import org.db3.airmq.features.common.chart.ChartConfig
import org.db3.airmq.features.common.chart.ChartDataset
-import org.db3.airmq.features.common.chart.generateSineWaveData
import org.db3.airmq.features.common.metric.SensorType
import org.db3.airmq.ui.theme.ChartBackground
import org.db3.airmq.ui.theme.ChartFill
+import org.db3.airmq.ui.theme.SensorDust1
+import org.db3.airmq.ui.theme.SensorDust10
+import org.db3.airmq.ui.theme.SensorDust25
import androidx.compose.ui.graphics.Color
object DashboardScreenContract {
@@ -19,14 +21,19 @@ object DashboardScreenContract {
selectedSensor: SensorType = SensorType.DUST,
currentPage: Int = 0
): State {
- val chartData = when (selectedSensor) {
- SensorType.DUST -> ChartDataset.Single(generateSineWaveData(amplitude = 8f, offset = 10f, periodCount = 1.5f))
- SensorType.RADIOACTIVITY -> ChartDataset.Single(generateSineWaveData(amplitude = 0.15f, offset = 0.2f, periodCount = 1f))
- SensorType.TEMPERATURE -> ChartDataset.Single(generateSineWaveData(amplitude = 5f, offset = 15f, periodCount = 2f))
- SensorType.HUMIDITY -> ChartDataset.Single(generateSineWaveData(amplitude = 15f, offset = 55f, periodCount = 1.2f))
- SensorType.PRESSURE -> ChartDataset.Single(generateSineWaveData(amplitude = 10f, offset = 740f, periodCount = 0.8f))
- else -> ChartDataset.Single(generateSineWaveData())
- }
+ val previewRows = DashboardChartMapper.previewStaticRows()
+ val chartData = DashboardChartMapper.chartDataset(previewRows, selectedSensor)
+ val center = LocalContext.current.getString(
+ when (selectedSensor) {
+ SensorType.DUST -> R.string.sensor_dust
+ SensorType.RADIOACTIVITY -> R.string.sensor_radioactivity
+ SensorType.TEMPERATURE -> R.string.sensor_temperature
+ SensorType.HUMIDITY -> R.string.sensor_humidity
+ SensorType.PRESSURE -> R.string.sensor_pressure
+ SensorType.CO2 -> R.string.sensor_co2
+ SensorType.VOC -> R.string.sensor_voc
+ }
+ )
val chartConfig = ChartConfig(
lineColor = Color.White,
fillColor = ChartFill,
@@ -35,16 +42,12 @@ object DashboardScreenContract {
leftTimeLabel = "Yesterday",
rightTimeLabel = "Now",
unit = selectedSensor.units(),
- centerLabel = LocalContext.current.getString(
- when (selectedSensor) {
- SensorType.DUST -> R.string.sensor_dust
- SensorType.RADIOACTIVITY -> R.string.sensor_radioactivity
- SensorType.TEMPERATURE -> R.string.sensor_temperature
- SensorType.HUMIDITY -> R.string.sensor_humidity
- SensorType.PRESSURE -> R.string.sensor_pressure
- else -> R.string.sensor_dust
- }
- )
+ centerLabel = center,
+ multiLineColors = if (selectedSensor == SensorType.DUST) {
+ listOf(SensorDust10, SensorDust25, SensorDust1)
+ } else {
+ null
+ }
)
return State(
city = city,
diff --git a/app/src/main/kotlin/org/db3/airmq/features/dashboard/DashboardViewModel.kt b/app/src/main/kotlin/org/db3/airmq/features/dashboard/DashboardViewModel.kt
index 2985c7b..a6c8fac 100644
--- a/app/src/main/kotlin/org/db3/airmq/features/dashboard/DashboardViewModel.kt
+++ b/app/src/main/kotlin/org/db3/airmq/features/dashboard/DashboardViewModel.kt
@@ -5,57 +5,66 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
+import javax.inject.Inject
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.flow.launchIn
-import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
-import javax.inject.Inject
-import org.db3.airmq.sdk.city.CityService
import org.db3.airmq.R
import org.db3.airmq.features.common.chart.ChartConfig
import org.db3.airmq.features.common.chart.ChartDataset
-import org.db3.airmq.features.common.chart.generateSineWaveData
import org.db3.airmq.features.common.metric.SensorType
+import org.db3.airmq.sdk.city.CityService
+import org.db3.airmq.sdk.city.DashboardCityContext
+import org.db3.airmq.sdk.dashboard.DashboardMetricsRepository
+import org.db3.airmq.sdk.dashboard.SensorSampleRow
import org.db3.airmq.ui.theme.ChartBackground
import org.db3.airmq.ui.theme.ChartFill
+import org.db3.airmq.ui.theme.SensorDust1
+import org.db3.airmq.ui.theme.SensorDust10
+import org.db3.airmq.ui.theme.SensorDust25
import androidx.compose.ui.graphics.Color
@HiltViewModel
class DashboardViewModel @Inject constructor(
@ApplicationContext private val context: Context,
- private val cityService: CityService
+ private val cityService: CityService,
+ private val dashboardMetricsRepository: DashboardMetricsRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(initialState())
val uiState: StateFlow = _uiState.asStateFlow()
+ private val _actions = MutableSharedFlow(extraBufferCapacity = 1)
+ val actions: SharedFlow = _actions.asSharedFlow()
+
+ private var cachedAverageRows: List = emptyList()
+
init {
viewModelScope.launch {
- cityService.observeSelectedCity().collect { city ->
- _uiState.update { it.copy(city = city) }
+ cityService.observeDashboardCityContext().collectLatest { _ ->
+ val ctx = cityService.getResolvedDashboardCityContext()
+ loadDashboardData(ctx)
}
}
}
- private val _actions = MutableSharedFlow(extraBufferCapacity = 1)
- val actions: SharedFlow = _actions.asSharedFlow()
-
fun onEvent(event: DashboardScreenContract.Event) {
when (event) {
DashboardScreenContract.Event.CitySelectorClicked -> _actions.tryEmit(DashboardScreenContract.Action.OpenCity)
is DashboardScreenContract.Event.GaugeSelected -> {
+ val sensor = event.sensor
_uiState.update { state ->
state.copy(
- selectedSensor = event.sensor,
- chartData = chartDataFor(event.sensor),
- chartConfig = chartConfigFor(event.sensor),
- chartSensorLabel = chartLabelFor(event.sensor)
+ selectedSensor = sensor,
+ chartData = DashboardChartMapper.chartDataset(cachedAverageRows, sensor),
+ chartConfig = chartConfigFor(sensor),
+ chartSensorLabel = chartLabelFor(sensor),
)
}
}
@@ -68,53 +77,50 @@ class DashboardViewModel @Inject constructor(
private fun initialState(): DashboardScreenContract.State {
val selected = SensorType.DUST
return DashboardScreenContract.State(
- city = "Minsk",
- gaugeValues = dummyGaugeValues(),
+ city = cityService.getDashboardCityDisplayName(),
+ gaugeValues = SensorType.entries.associateWith { null },
selectedSensor = selected,
currentPage = 0,
- chartData = chartDataFor(selected),
+ chartData = ChartDataset.Single(emptyList()),
chartConfig = chartConfigFor(selected),
- chartSensorLabel = chartLabelFor(selected)
+ chartSensorLabel = chartLabelFor(selected),
)
}
- private fun dummyGaugeValues(): Map = mapOf(
- SensorType.DUST to 6f,
- SensorType.RADIOACTIVITY to 0f,
- SensorType.TEMPERATURE to 3f,
- SensorType.HUMIDITY to 65f,
- SensorType.PRESSURE to 745f
- )
-
- private fun chartDataFor(sensor: SensorType): ChartDataset = when (sensor) {
- SensorType.DUST -> ChartDataset.Single(
- generateSineWaveData(amplitude = 8f, offset = 10f, periodCount = 1.5f)
- )
- SensorType.RADIOACTIVITY -> ChartDataset.Single(
- generateSineWaveData(amplitude = 0.15f, offset = 0.2f, periodCount = 1f)
- )
- SensorType.TEMPERATURE -> ChartDataset.Single(
- generateSineWaveData(amplitude = 5f, offset = 15f, periodCount = 2f)
- )
- SensorType.HUMIDITY -> ChartDataset.Single(
- generateSineWaveData(amplitude = 15f, offset = 55f, periodCount = 1.2f)
- )
- SensorType.PRESSURE -> ChartDataset.Single(
- generateSineWaveData(amplitude = 10f, offset = 740f, periodCount = 0.8f)
- )
- else -> ChartDataset.Single(generateSineWaveData())
+ private suspend fun loadDashboardData(ctx: DashboardCityContext) {
+ val result = dashboardMetricsRepository.fetchCityDashboard(ctx)
+ val data = result.getOrNull()
+ cachedAverageRows = data?.averageRows.orEmpty()
+ val sensor = _uiState.value.selectedSensor
+ _uiState.update { state ->
+ state.copy(
+ city = ctx.displayName,
+ gaugeValues = DashboardChartMapper.gaugeValues(data?.lastRow),
+ chartData = DashboardChartMapper.chartDataset(cachedAverageRows, sensor),
+ chartConfig = chartConfigFor(sensor),
+ chartSensorLabel = chartLabelFor(sensor),
+ )
+ }
}
- private fun chartConfigFor(sensor: SensorType): ChartConfig = ChartConfig(
- lineColor = Color.White,
- fillColor = ChartFill,
- backgroundColor = ChartBackground,
- labelColor = Color.White,
- leftTimeLabel = "Yesterday",
- rightTimeLabel = "Now",
- unit = sensor.units(),
- centerLabel = chartLabelFor(sensor)
- )
+ private fun chartConfigFor(sensor: SensorType): ChartConfig {
+ val label = chartLabelFor(sensor)
+ val base = ChartConfig(
+ lineColor = Color.White,
+ fillColor = ChartFill,
+ backgroundColor = ChartBackground,
+ labelColor = Color.White,
+ leftTimeLabel = "Yesterday",
+ rightTimeLabel = "Now",
+ unit = sensor.units(),
+ centerLabel = label,
+ )
+ return if (sensor == SensorType.DUST) {
+ base.copy(multiLineColors = listOf(SensorDust10, SensorDust25, SensorDust1))
+ } else {
+ base
+ }
+ }
private fun chartLabelFor(sensor: SensorType): String = context.getString(
when (sensor) {
@@ -123,7 +129,8 @@ class DashboardViewModel @Inject constructor(
SensorType.TEMPERATURE -> R.string.sensor_temperature
SensorType.HUMIDITY -> R.string.sensor_humidity
SensorType.PRESSURE -> R.string.sensor_pressure
- else -> R.string.sensor_dust
+ SensorType.CO2 -> R.string.sensor_co2
+ SensorType.VOC -> R.string.sensor_voc
}
)
}
diff --git a/app/src/main/kotlin/org/db3/airmq/features/map/MapScreen.kt b/app/src/main/kotlin/org/db3/airmq/features/map/MapScreen.kt
index caf5aed..4268747 100644
--- a/app/src/main/kotlin/org/db3/airmq/features/map/MapScreen.kt
+++ b/app/src/main/kotlin/org/db3/airmq/features/map/MapScreen.kt
@@ -5,6 +5,7 @@ import android.graphics.Canvas
import android.graphics.drawable.BitmapDrawable
import android.os.Handler
import android.os.Looper
+import android.view.ViewTreeObserver
import android.view.LayoutInflater
import android.widget.Toast
import android.widget.ImageView
@@ -348,8 +349,26 @@ private fun rebuildAirMqMapOverlays(
val zoomScale = ((18.5 - zoom) / 11.0).coerceIn(0.38, 1.0).toFloat()
val clusterDistancePx = (baseClusterPx * zoomScale).coerceAtLeast(18f * density)
if (map.width <= 0 || map.height <= 0) {
- map.post {
- if (map.width > 0 && map.height > 0) {
+ // Compose AndroidView often invokes update before the MapView is measured; one-shot post
+ // can still see 0×0 and then nothing retriggers rebuild until the user pans the map.
+ val vto = map.viewTreeObserver
+ if (!vto.isAlive) {
+ map.post {
+ rebuildAirMqMapOverlays(
+ map,
+ items,
+ onMarkerClick,
+ clusterEnabled,
+ centerOnMarker,
+ initialCameraDone
+ )
+ }
+ return
+ }
+ val listener = object : ViewTreeObserver.OnGlobalLayoutListener {
+ override fun onGlobalLayout() {
+ if (map.width <= 0 || map.height <= 0) return
+ map.viewTreeObserver.removeOnGlobalLayoutListener(this)
rebuildAirMqMapOverlays(
map,
items,
@@ -360,6 +379,7 @@ private fun rebuildAirMqMapOverlays(
)
}
}
+ vto.addOnGlobalLayoutListener(listener)
return
}
diff --git a/sdk/src/main/graphql/AuthGoogleNew.graphql b/sdk/src/main/graphql/AuthGoogleNew.graphql
index e06c71e..bd1e822 100644
--- a/sdk/src/main/graphql/AuthGoogleNew.graphql
+++ b/sdk/src/main/graphql/AuthGoogleNew.graphql
@@ -1,5 +1,5 @@
mutation AuthGoogleNew($accessToken: String!) {
- authGoogleNew(input: { accessToken: $accessToken }) {
+ authGoogleApp(input: { accessToken: $accessToken }) {
token
name
email
diff --git a/sdk/src/main/graphql/CityAverage.graphql b/sdk/src/main/graphql/CityAverage.graphql
new file mode 100644
index 0000000..3f1697d
--- /dev/null
+++ b/sdk/src/main/graphql/CityAverage.graphql
@@ -0,0 +1,18 @@
+query CityAverage($filter: MeanFilter!) {
+ cityAverage(filter: $filter) {
+ deviceId
+ time
+ Temp
+ Hum
+ Press
+ PMS1
+ PMS25
+ PMS10
+ radRg
+ PPM
+ IKAV
+ CO2
+ VOC
+ AQI
+ }
+}
diff --git a/sdk/src/main/graphql/CityAverageLast.graphql b/sdk/src/main/graphql/CityAverageLast.graphql
new file mode 100644
index 0000000..7de260e
--- /dev/null
+++ b/sdk/src/main/graphql/CityAverageLast.graphql
@@ -0,0 +1,18 @@
+query CityAverageLast($filter: LastFilter!) {
+ cityAverageLast(filter: $filter) {
+ deviceId
+ time
+ Temp
+ Hum
+ Press
+ PMS1
+ PMS25
+ PMS10
+ radRg
+ PPM
+ IKAV
+ CO2
+ VOC
+ AQI
+ }
+}
diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/AuthServiceImpl.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/AuthServiceImpl.kt
index 667fc5b..1370275 100644
--- a/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/AuthServiceImpl.kt
+++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/AuthServiceImpl.kt
@@ -49,7 +49,7 @@ class FirebaseAuthService @Inject constructor(
response.errors?.firstOrNull()?.let { gqlError ->
throw IllegalStateException(gqlError.message)
}
- return response.data?.authGoogleNew?.token
+ return response.data?.authGoogleApp?.token
?: error("Backend auth exchange succeeded without API token.")
}
}
diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/city/CityService.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/city/CityService.kt
index b0dca2f..fd62396 100644
--- a/sdk/src/main/kotlin/org/db3/airmq/sdk/city/CityService.kt
+++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/city/CityService.kt
@@ -16,11 +16,32 @@ interface CityService {
*/
fun observeSelectedCity(): Flow
+ /**
+ * Flow of display name, GraphQL city id, and English name for dashboard data loading.
+ */
+ fun observeDashboardCityContext(): Flow
+
+ /**
+ * If [DashboardCityContext.cityId] is missing, tries to resolve it from the local city DB
+ * using the stored English name and updates preferences.
+ */
+ suspend fun refreshDashboardCityIdentity()
+
+ /**
+ * Runs [refreshDashboardCityIdentity] then returns the current [DashboardCityContext] (for API calls).
+ */
+ suspend fun getResolvedDashboardCityContext(): DashboardCityContext
+
/**
* Returns the selected dashboard city display name (localized).
*/
suspend fun getSelectedCity(): String
+ /**
+ * Same as [getSelectedCity] but synchronous snapshot (for initial UI state).
+ */
+ fun getDashboardCityDisplayName(): String
+
/**
* Called on app launch when city_init is false.
* Fetches cities if DB empty, resolves city from location or country, and stores result.
diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/city/CityServiceImpl.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/city/CityServiceImpl.kt
index ef8c479..f9923db 100644
--- a/sdk/src/main/kotlin/org/db3/airmq/sdk/city/CityServiceImpl.kt
+++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/city/CityServiceImpl.kt
@@ -6,6 +6,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.map
import org.db3.airmq.sdk.city.data.local.CityLocalDataSource
import org.db3.airmq.sdk.city.data.remote.CityRemoteDataSource
import org.db3.airmq.sdk.city.domain.City
@@ -29,11 +30,15 @@ class CityServiceImpl @Inject constructor(
) : CityService {
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
- private val _selectedCityFlow = MutableStateFlow(getStoredCityDisplayName())
+ private val _cityContextFlow = MutableStateFlow(readDashboardContextFromPrefs())
- override fun observeSelectedCity(): Flow = _selectedCityFlow
+ override fun observeSelectedCity(): Flow = _cityContextFlow.map { it.displayName }
- override suspend fun getSelectedCity(): String = getStoredCityDisplayName()
+ override fun observeDashboardCityContext(): Flow = _cityContextFlow.asStateFlow()
+
+ override suspend fun getSelectedCity(): String = _cityContextFlow.value.displayName
+
+ override fun getDashboardCityDisplayName(): String = _cityContextFlow.value.displayName
override suspend fun initialize(
hasLocationPermission: Boolean,
@@ -46,24 +51,33 @@ class CityServiceImpl @Inject constructor(
val resolvedCity = resolveCity(cities, hasLocationPermission, location, countryCode)
val displayName = resolvedCity?.getLocalizedName(Locale.getDefault().language) ?: DEFAULT_CITY_NAME
- prefs.edit()
+ val editor = prefs.edit()
.putString(KEY_DASHBOARD_CITY, displayName)
.putString(KEY_DASHBOARD_CITY_EN, resolvedCity?.nameEn ?: DEFAULT_CITY_NAME)
.putBoolean(KEY_CITY_INIT, true)
- .apply()
-
- _selectedCityFlow.value = displayName
+ if (resolvedCity != null) {
+ editor.putString(KEY_DASHBOARD_CITY_ID, resolvedCity.id)
+ } else {
+ editor.remove(KEY_DASHBOARD_CITY_ID)
+ }
+ editor.apply()
+ pushContextUpdate()
}
override suspend fun setSelectedCity(cityId: String): Result = runCatching {
val cities = cityLocalDataSource.getAllCities()
val city = cities.find { it.id == cityId } ?: cities.find { it.nameEn == cityId }
val displayName = city?.getLocalizedName(Locale.getDefault().language) ?: cityId
- prefs.edit()
+ val editor = prefs.edit()
.putString(KEY_DASHBOARD_CITY, displayName)
.putString(KEY_DASHBOARD_CITY_EN, city?.nameEn ?: cityId)
- .apply()
- _selectedCityFlow.value = displayName
+ if (city != null) {
+ editor.putString(KEY_DASHBOARD_CITY_ID, city.id)
+ } else {
+ editor.remove(KEY_DASHBOARD_CITY_ID)
+ }
+ editor.apply()
+ pushContextUpdate()
}
override fun isCityInitComplete(): Boolean = prefs.getBoolean(KEY_CITY_INIT, false)
@@ -84,11 +98,26 @@ class CityServiceImpl @Inject constructor(
prefs.edit()
.putString(KEY_DASHBOARD_CITY, displayName)
.putString(KEY_DASHBOARD_CITY_EN, resolvedCity.nameEn)
+ .putString(KEY_DASHBOARD_CITY_ID, resolvedCity.id)
.apply()
- _selectedCityFlow.value = displayName
+ pushContextUpdate()
}
}
+ override suspend fun refreshDashboardCityIdentity() {
+ if (!prefs.getString(KEY_DASHBOARD_CITY_ID, null).isNullOrBlank()) return
+ val nameEn = prefs.getString(KEY_DASHBOARD_CITY_EN, null) ?: return
+ val city = cityLocalDataSource.getAllCities()
+ .find { it.nameEn.equals(nameEn, ignoreCase = true) } ?: return
+ prefs.edit().putString(KEY_DASHBOARD_CITY_ID, city.id).apply()
+ pushContextUpdate()
+ }
+
+ override suspend fun getResolvedDashboardCityContext(): DashboardCityContext {
+ refreshDashboardCityIdentity()
+ return _cityContextFlow.value
+ }
+
override suspend fun getCitiesGroupedByCountry(localeLanguage: String): List {
if (cityLocalDataSource.isEmpty()) return emptyList()
val cities = cityLocalDataSource.getAllCities()
@@ -103,8 +132,16 @@ class CityServiceImpl @Inject constructor(
return grouped
}
- private fun getStoredCityDisplayName(): String =
- prefs.getString(KEY_DASHBOARD_CITY, DEFAULT_CITY_NAME) ?: DEFAULT_CITY_NAME
+ private fun readDashboardContextFromPrefs(): DashboardCityContext {
+ val display = prefs.getString(KEY_DASHBOARD_CITY, DEFAULT_CITY_NAME) ?: DEFAULT_CITY_NAME
+ val nameEn = prefs.getString(KEY_DASHBOARD_CITY_EN, DEFAULT_CITY_NAME) ?: DEFAULT_CITY_NAME
+ val id = prefs.getString(KEY_DASHBOARD_CITY_ID, null)?.takeIf { it.isNotBlank() }
+ return DashboardCityContext(displayName = display, cityId = id, cityNameEn = nameEn)
+ }
+
+ private fun pushContextUpdate() {
+ _cityContextFlow.value = readDashboardContextFromPrefs()
+ }
private suspend fun ensureCitiesInDb(): List {
if (!cityLocalDataSource.isEmpty()) {
@@ -161,6 +198,7 @@ class CityServiceImpl @Inject constructor(
private const val PREFS_NAME = "airmq_city"
private const val KEY_DASHBOARD_CITY = "dashboard_city"
private const val KEY_DASHBOARD_CITY_EN = "dashboard_city_en"
+ private const val KEY_DASHBOARD_CITY_ID = "dashboard_city_id"
private const val KEY_CITY_INIT = "city_init"
private const val KEY_DASHBOARD_CITY_AUTO = "dashboard_city_auto"
}
diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/city/DashboardCityContext.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/city/DashboardCityContext.kt
new file mode 100644
index 0000000..75d0a3d
--- /dev/null
+++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/city/DashboardCityContext.kt
@@ -0,0 +1,14 @@
+package org.db3.airmq.sdk.city
+
+/**
+ * Selected dashboard city for UI and GraphQL city average filters.
+ *
+ * @param displayName Localized label shown in the dashboard header.
+ * @param cityId GraphQL / Room city id when known (may be null for legacy prefs).
+ * @param cityNameEn English city name; used as API fallback when [cityId] is absent.
+ */
+data class DashboardCityContext(
+ val displayName: String,
+ val cityId: String?,
+ val cityNameEn: String,
+)
diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/dashboard/DashboardMetricsRepository.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/dashboard/DashboardMetricsRepository.kt
new file mode 100644
index 0000000..a35c0e2
--- /dev/null
+++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/dashboard/DashboardMetricsRepository.kt
@@ -0,0 +1,19 @@
+package org.db3.airmq.sdk.dashboard
+
+import org.db3.airmq.sdk.city.DashboardCityContext
+
+/**
+ * Fetches city-aggregated sensor series and latest values for the dashboard.
+ */
+interface DashboardMetricsRepository {
+
+ /**
+ * Loads mean buckets over the last 24 hours and the latest snapshot for [context].
+ */
+ suspend fun fetchCityDashboard(context: DashboardCityContext): Result
+}
+
+data class CityDashboardData(
+ val averageRows: List,
+ val lastRow: SensorSampleRow?,
+)
diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/dashboard/DashboardMetricsRepositoryImpl.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/dashboard/DashboardMetricsRepositoryImpl.kt
new file mode 100644
index 0000000..69a9808
--- /dev/null
+++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/dashboard/DashboardMetricsRepositoryImpl.kt
@@ -0,0 +1,136 @@
+package org.db3.airmq.sdk.dashboard
+
+import android.util.Log
+import com.apollographql.apollo.ApolloClient
+import com.apollographql.apollo.api.Optional
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import java.util.TimeZone
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlinx.coroutines.async
+import kotlinx.coroutines.coroutineScope
+import org.db3.airmq.sdk.CityAverageLastQuery
+import org.db3.airmq.sdk.CityAverageQuery
+import org.db3.airmq.sdk.city.DashboardCityContext
+import org.db3.airmq.sdk.type.LastFilter
+import org.db3.airmq.sdk.type.MeanFilter
+
+@Singleton
+class DashboardMetricsRepositoryImpl @Inject constructor(
+ private val apolloClient: ApolloClient,
+) : DashboardMetricsRepository {
+
+ override suspend fun fetchCityDashboard(context: DashboardCityContext): Result = runCatching {
+ val now = System.currentTimeMillis()
+ val from = now - HOURS_24_MS
+ val tFrom = formatUtcIso(from)
+ val tTo = formatUtcIso(now)
+
+ val meanFilter = MeanFilter(
+ cityId = context.cityId?.let { Optional.Present(it) } ?: Optional.Absent,
+ cityName = Optional.Present(context.cityNameEn),
+ t_from = Optional.Present(tFrom),
+ t_to = Optional.Present(tTo),
+ interval_h = Optional.Present(1),
+ interval_d = Optional.Present(0),
+ interval_m = Optional.Present(0),
+ )
+ val lastFilter = LastFilter(
+ cityId = context.cityId?.let { Optional.Present(it) } ?: Optional.Absent,
+ cityName = Optional.Present(context.cityNameEn),
+ interval_m = Optional.Present(15),
+ )
+
+ Log.d(
+ TAG,
+ "fetchCityDashboard request: display=${context.displayName} cityId=${context.cityId} " +
+ "cityNameEn=${context.cityNameEn} t_from=$tFrom t_to=$tTo " +
+ "meanFilter=$meanFilter lastFilter=$lastFilter"
+ )
+
+ coroutineScope {
+ val avgDeferred = async {
+ apolloClient.query(CityAverageQuery(filter = meanFilter)).execute()
+ }
+ val lastDeferred = async {
+ apolloClient.query(CityAverageLastQuery(filter = lastFilter)).execute()
+ }
+ val avgResponse = avgDeferred.await()
+ val lastResponse = lastDeferred.await()
+
+ Log.d(
+ TAG,
+ "CityAverage raw: data=${avgResponse.data} errors=${avgResponse.errors} " +
+ "exception=${avgResponse.exception?.message}"
+ )
+ Log.d(
+ TAG,
+ "CityAverageLast raw: data=${lastResponse.data} errors=${lastResponse.errors} " +
+ "exception=${lastResponse.exception?.message}"
+ )
+
+ avgResponse.exception?.let { throw it }
+ avgResponse.errors?.firstOrNull()?.let { throw IllegalStateException(it.message) }
+ lastResponse.exception?.let { throw it }
+ lastResponse.errors?.firstOrNull()?.let { throw IllegalStateException(it.message) }
+
+ val rows = avgResponse.data?.cityAverage.orEmpty().mapNotNull { row ->
+ row?.let { mapCityAverageRow(it) }
+ }
+ val lastRow = lastResponse.data?.cityAverageLast?.let { mapLastRow(it) }
+
+ Log.d(
+ TAG,
+ "fetchCityDashboard parsed: ${rows.size} series rows, lastRow=${lastRow != null} " +
+ "(last epoch=${lastRow?.epochMillis})"
+ )
+
+ CityDashboardData(averageRows = rows, lastRow = lastRow)
+ }
+ }
+
+ private fun formatUtcIso(epochMillis: Long): String {
+ val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
+ sdf.timeZone = TimeZone.getTimeZone("UTC")
+ return sdf.format(Date(epochMillis))
+ }
+
+ private fun mapCityAverageRow(row: CityAverageQuery.CityAverage): SensorSampleRow? {
+ val t = GraphqlDateTimeParser.parseToEpochMillis(row.time) ?: return null
+ return SensorSampleRow(
+ epochMillis = t,
+ temp = row.Temp?.toFloat(),
+ hum = row.Hum?.toFloat(),
+ press = row.Press?.toFloat(),
+ pms1 = row.PMS1?.toFloat(),
+ pms25 = row.PMS25?.toFloat(),
+ pms10 = row.PMS10?.toFloat(),
+ radRg = row.radRg?.toFloat(),
+ ppm = row.PPM?.toFloat(),
+ ikav = row.IKAV?.toFloat(),
+ )
+ }
+
+ private fun mapLastRow(row: CityAverageLastQuery.CityAverageLast): SensorSampleRow? {
+ val t = GraphqlDateTimeParser.parseToEpochMillis(row.time) ?: return null
+ return SensorSampleRow(
+ epochMillis = t,
+ temp = row.Temp?.toFloat(),
+ hum = row.Hum?.toFloat(),
+ press = row.Press?.toFloat(),
+ pms1 = row.PMS1?.toFloat(),
+ pms25 = row.PMS25?.toFloat(),
+ pms10 = row.PMS10?.toFloat(),
+ radRg = row.radRg?.toFloat(),
+ ppm = row.PPM?.toFloat(),
+ ikav = row.IKAV?.toFloat(),
+ )
+ }
+
+ private companion object {
+ private const val TAG = "DashboardApi"
+ private const val HOURS_24_MS = 24L * 60L * 60L * 1000L
+ }
+}
diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/dashboard/GraphqlDateTimeParser.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/dashboard/GraphqlDateTimeParser.kt
new file mode 100644
index 0000000..9e99753
--- /dev/null
+++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/dashboard/GraphqlDateTimeParser.kt
@@ -0,0 +1,47 @@
+package org.db3.airmq.sdk.dashboard
+
+import java.text.ParseException
+import java.text.SimpleDateFormat
+import java.util.Locale
+import java.util.TimeZone
+
+/**
+ * Parses GraphQL [DateTime] values returned as [Any] (typically ISO-8601 string) from Apollo.
+ */
+internal object GraphqlDateTimeParser {
+
+ private val utcPatterns = listOf(
+ "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'",
+ "yyyy-MM-dd'T'HH:mm:ss'Z'",
+ "yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
+ "yyyy-MM-dd'T'HH:mm:ssXXX",
+ )
+
+ fun parseToEpochMillis(value: Any?): Long? {
+ if (value == null) return null
+ when (value) {
+ is String -> {
+ for (pattern in utcPatterns) {
+ try {
+ val sdf = SimpleDateFormat(pattern, Locale.US)
+ sdf.timeZone = TimeZone.getTimeZone("UTC")
+ return sdf.parse(value)?.time
+ } catch (_: ParseException) {
+ continue
+ }
+ }
+ try {
+ val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US)
+ return sdf.parse(value)?.time
+ } catch (_: ParseException) {
+ return null
+ }
+ }
+ is Number -> {
+ val v = value.toLong()
+ return if (v in 1_000_000_000L until 1_000_000_000_000L) v * 1000L else v
+ }
+ else -> return null
+ }
+ }
+}
diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/dashboard/SensorSampleRow.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/dashboard/SensorSampleRow.kt
new file mode 100644
index 0000000..83704e0
--- /dev/null
+++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/dashboard/SensorSampleRow.kt
@@ -0,0 +1,15 @@
+package org.db3.airmq.sdk.dashboard
+
+/** Normalized row from city average GraphQL queries for dashboard mapping. */
+data class SensorSampleRow(
+ val epochMillis: Long,
+ val temp: Float?,
+ val hum: Float?,
+ val press: Float?,
+ val pms1: Float?,
+ val pms25: Float?,
+ val pms10: Float?,
+ val radRg: Float?,
+ val ppm: Float?,
+ val ikav: Float?,
+)
diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/di/SDKModule.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/di/SDKModule.kt
index 525568c..4b58934 100644
--- a/sdk/src/main/kotlin/org/db3/airmq/sdk/di/SDKModule.kt
+++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/di/SDKModule.kt
@@ -14,6 +14,8 @@ import org.db3.airmq.sdk.auth.FirebaseAuthService
import org.db3.airmq.sdk.auth.FirebaseSessionManager
import org.db3.airmq.sdk.auth.FirebaseSessionManagerImpl
import org.db3.airmq.sdk.auth.SharedPreferencesApiTokenStore
+import org.db3.airmq.sdk.dashboard.DashboardMetricsRepository
+import org.db3.airmq.sdk.dashboard.DashboardMetricsRepositoryImpl
import org.db3.airmq.sdk.map.MapServiceImpl
import org.db3.airmq.sdk.map.MapService
import org.db3.airmq.sdk.settings.SettingsService
@@ -60,6 +62,12 @@ abstract class SDKBindModule {
@Singleton
abstract fun bindMapService(impl: MapServiceImpl): MapService
+ @Binds
+ @Singleton
+ abstract fun bindDashboardMetricsRepository(
+ impl: DashboardMetricsRepositoryImpl
+ ): DashboardMetricsRepository
+
@Binds
@Singleton
abstract fun bindSettingsService(impl: SettingsServiceImpl): SettingsService
diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/network/ApolloLoggingInterceptor.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/network/ApolloLoggingInterceptor.kt
index e10e1a6..a784612 100644
--- a/sdk/src/main/kotlin/org/db3/airmq/sdk/network/ApolloLoggingInterceptor.kt
+++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/network/ApolloLoggingInterceptor.kt
@@ -9,30 +9,59 @@ import com.apollographql.apollo.interceptor.ApolloInterceptorChain
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.onEach
+/**
+ * Logs every GraphQL operation name, document, variable payload ([Operation] `toString`),
+ * and response data or errors to logcat (tag [TAG]).
+ */
class ApolloLoggingInterceptor : ApolloInterceptor {
override fun intercept(
request: ApolloRequest,
chain: ApolloInterceptorChain
): Flow> {
- val operationName = request.operation.name()
- val requestData = request.operation.toString()
- Log.d(TAG, "Apollo request -> $operationName, data=$requestData")
+ val operation = request.operation
+ val operationName = operation.name()
+ Log.d(TAG, "---------- GraphQL request: $operationName ----------")
+ logChunked("$TAG.request.$operationName.doc", operation.document())
+ Log.d(TAG, "Variables / operation: $operation")
return chain.proceed(request).onEach { response ->
- val errorsCount = response.errors?.size ?: 0
if (response.exception != null) {
Log.e(
TAG,
- "Apollo response <- $operationName failed: ${response.exception?.message}"
+ "---------- GraphQL response: $operationName FAILED ----------",
+ response.exception
)
} else {
- Log.d(
- TAG,
- "Apollo response <- $operationName success, errors=$errorsCount, data=${response.data}"
- )
+ Log.d(TAG, "---------- GraphQL response: $operationName ----------")
+ val errors = response.errors
+ if (!errors.isNullOrEmpty()) {
+ errors.forEachIndexed { i, err ->
+ Log.w(TAG, "errors[$i]: ${err.message} (locations=${err.locations})")
+ }
+ } else {
+ Log.d(TAG, "errors: none")
+ }
+ val dataStr = response.data?.toString() ?: "null"
+ logChunked("$TAG.response.$operationName.data", dataStr)
}
}
}
+ /** Android single-line log limit is ~4k; split large payloads. */
+ private fun logChunked(label: String, text: String, chunkSize: Int = 3500) {
+ if (text.length <= chunkSize) {
+ Log.d(TAG, "$label: $text")
+ return
+ }
+ var start = 0
+ var part = 0
+ while (start < text.length) {
+ val end = minOf(start + chunkSize, text.length)
+ Log.d(TAG, "$label [part $part]: ${text.substring(start, end)}")
+ part++
+ start = end
+ }
+ }
+
private companion object {
private const val TAG = "ApolloNetwork"
}