Google Sheets Viewer for Wear OS

Build a standalone Wear OS app that fetches and displays Google Sheets on your wrist.

Introduction

30 Second Summary

You glance at your smartwatch dozens of times a day for notifications, weather, and step counts. But what about the data you actually work with, like a task tracker or a budget spreadsheet?

In this project, you will build a standalone Google Sheets viewer that runs directly on your Wear OS watch. You will sign in with your Google account on the watch, pick a spreadsheet, and view its data in a wrist-friendly scrollable list with offline caching.

What You'll Build

Tap the app on your wrist, sign in, select a spreadsheet, and scroll through real rows of data on the round display, even when your phone is across the room.

By the end of this project, you'll have:

  • A Google sign-in flow that authenticates directly on your watch using Credential Manager, with no phone required.
  • A spreadsheet browser that lists your real Google Sheets and displays cell data in a scrollable, wrist-optimized layout built with Compose for Wear OS and Material 3.
  • Offline caching that stores your last-synced spreadsheet locally, so you can view your data in airplane mode or without a paired phone.
  • Secret Mission: Add a manual refresh button with a loading indicator and a "last synced" timestamp display.

Are there any prerequisites?

This project assumes some experience with Android development and Kotlin. You will need Android Studio installed, a Google Cloud account, and a Wear OS 7 watch or emulator.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters to you. Taking a moment to commit now helps you stay focused as you work through each step.

Set Up Your Wear OS Project and Google Cloud

To build a Sheets viewer that runs on your wrist, you need two foundations: an Android Studio project configured for Wear OS, and a Google Cloud project that grants your app access to the Google Drive API.

In this step, you will create the Wear OS project, configure Google Cloud with the right API and OAuth credentials, and add the dependencies your app needs to authenticate and fetch data.

In this step, get ready to:
  • Create a Wear OS project in Android Studio and run it on your watch.
  • Set up Google Cloud with the Drive API and OAuth client IDs.
  • Add authentication and networking dependencies to your project.
Create the Wear OS project in Android Studio

Android Studio includes a project template specifically for Wear OS apps. This template sets up Compose for Wear OS with a basic "Hello World" screen you can run immediately on your watch.

  • In Android Studio, click File in the top menu bar.
  • Click New, then New Project.
  • In the project template list, select the Wear OS category on the left.
  • Select Empty Wear App and click Next.
  • Set the Name to SheetGlance.
  • Set the Minimum SDK to API 33.
  • Click Finish.

What about the target SDK?

The project wizard sets minSdk, but targetSdk and compileSdk are configured in your module-level build.gradle.kts. You will update those to 37 when you add dependencies later in this step.

Wait for Android Studio to finish creating and indexing the project. This may take a minute.

  • Make sure your Wear OS watch or emulator is connected and selected in the device dropdown at the top of Android Studio.
  • Click the green Run button (or press Shift+F10 on Windows, Ctrl+R on macOS) to build and deploy the app.

Before you check the result, what do you expect to see on the watch screen?

You should see a simple "Hello" text on the round watch display. This is the default Compose template confirming your project builds and runs correctly.

App not launching on the watch?

  • Make sure your watch or emulator is powered on and connected. For a physical watch, enable ADB debugging under Developer options.
  • If using an emulator, confirm you created a Wear OS system image (not a phone image) and it is running.
  • Check the Build tab in Android Studio for any Gradle sync errors.

Help me troubleshoot why my Wear OS app is not launching on my watch or emulator.

Configure Google Cloud Console

Your app needs to access the user's Google Sheets through the Drive API. To do this, you need a Google Cloud project with the API enabled and OAuth credentials that identify your app.

  • Open the Google Cloud Console at console.cloud.google.com.
  • Click the project dropdown at the top of the page.
  • Click New Project.
  • Name it SheetGlance and click Create.
  • Once the project is created, make sure it is selected in the project dropdown.

Now enable the Google Drive API for this project.

  • In the left navigation menu, click APIs & Services, then Library.
  • Type Google Drive API in the search bar and select it from the results.
  • Click Enable.

You should see the Drive API overview page with a confirmation that the API is enabled.

Next, configure the OAuth consent screen. This controls what users see when they sign in with Google.

  • In the left navigation menu, click APIs & Services, then OAuth consent screen.
  • Click Get started (or Configure Consent Screen if prompted).
  • Enter SheetGlance as the App name.
  • Select your email for the User support email field.
  • Select External for the user type.
  • Enter your email again under Developer contact information.
  • Click Save to complete the consent screen setup. This puts the consent screen in test mode.

What is test mode?

In test mode, only users you explicitly add as test users can sign in. This is fine for development. You do not need to go through Google's app verification process just to build and test your app.

Now you need your app's SHA-1 fingerprint to create the Android OAuth client. This fingerprint uniquely identifies your app's signing certificate.

  • Switch back to Android Studio.
  • Open the Terminal tab at the bottom of Android Studio.
  • Get your debug SHA-1 fingerprint by running this command:
./gradlew signingReport
  • Look for the SHA1 line under the Variant: debug section. Copy the full SHA-1 fingerprint (it looks like A7:89:E5:05:C8:17:...).

Windows users

On Windows, use gradlew signingReport (without the ./ prefix).

Now create the Android OAuth client ID.

  • Switch back to the Google Cloud Console.
  • In the left navigation menu, go to Google Auth platform, then Clients.
  • Click Create Client.
  • Select Android as the Application type.
  • Enter SheetGlance Android in the Name field.
  • Enter your app's package name (com.example.sheetglance or whatever you see in your AndroidManifest.xml) in the Package name field.
  • Paste the SHA-1 fingerprint you copied earlier into the SHA-1 certificate fingerprint field.
  • Click Create.

The newly created credential appears under OAuth 2.0 Client IDs.

Now create the Web application OAuth client ID. Credential Manager requires a web client ID for the Sign in with Google flow, even on Android.

  • Click Create Client again.
  • Select Web application as the Application type.
  • Enter SheetGlance Web in the Name field.
  • Leave the Authorized JavaScript origins and Authorized redirect URIs fields empty.
  • Click Create.
  • Copy the Client ID from the confirmation dialog and save it somewhere safe. It looks like 123456789-abc.apps.googleusercontent.com. You will use this web client ID in your Kotlin code in Step 3.

Why two OAuth client IDs?

The Android client ID verifies your app's identity using its package name and signing certificate. The web client ID is what Credential Manager sends to Google's authentication servers to complete the Sign in with Google flow. Both are required for OAuth to work on Wear OS.

Add dependencies to your project

The default project template includes Compose basics, but you need additional libraries for authentication, the Google identity layer, and network calls.

  • In Android Studio, open build.gradle.kts (the one labeled Module :app in the project tree, not the project-level one).
  • Find the android block and make sure compileSdk and targetSdk are both set to 37. If they show a different number, update them:
// Inside the android block
compileSdk = 37

defaultConfig {
    // ...
    minSdk = 33
    targetSdk = 37
    // ...
}

Why API 37?

Wear OS 7 is based on Android 17 (API level 37). Setting compileSdk and targetSdk to 37 ensures your app can use the latest platform APIs and behaves correctly on Wear OS 7. The minSdk of 33 ensures compatibility with older watches running Wear OS 3 and above.

  • Scroll down to the dependencies block. Add the following lines for Credential Manager, Google Identity, and OkHttp. Your template already includes the Compose BOM, Wear Material 3, and Wear Foundation. Add these new dependencies alongside the existing ones:
// Credential Manager for Google Sign-In
implementation("androidx.credentials:credentials:1.6.0")
implementation("androidx.credentials:credentials-play-services-auth:1.6.0")

// Google Identity library for Sign in with Google
implementation("com.google.android.libraries.identity.googleid:googleid:1.2.0")

// OkHttp for network calls to the Drive API
implementation("com.squareup.okhttp3:okhttp:5.5.0")

What do these dependencies do?

  • credentials and credentials-play-services-auth provide the Credential Manager API for handling sign-in flows directly on the watch.
  • googleid lets you extract Google ID tokens from the Credential Manager response.
  • okhttp is a lightweight HTTP client for making REST calls to the Google Drive API.
  • Verify that your dependencies block also includes these Wear OS libraries (the template should have added them automatically):
// Compose BOM (should already be present)
implementation(platform("androidx.compose:compose-bom:2026.06.00"))

// Wear Compose Material 3 (should already be present)
implementation("androidx.wear.compose:compose-material3:1.6.2")

// Wear Compose Foundation (should already be present)
implementation("androidx.wear.compose:compose-foundation:1.6.2")
  • Click Sync Now in the yellow banner that appears at the top of the editor.

Wait for Gradle sync to complete. The Build tab at the bottom should show "BUILD SUCCESSFUL".

  • Run the app again on your watch to confirm it still builds and launches correctly.

You should see the same "Hello" screen as before. The new dependencies do not change the UI yet.

Gradle sync failed?

  • Double-check that you added the dependency lines inside the dependencies { } block, not outside it.
  • Make sure there are no typos in the library coordinates or version numbers.
  • If you see a compileSdk error, confirm you set it to 37 and that Android Studio has the SDK platform for API 37 installed (check Tools > SDK Manager).

Help me fix a Gradle sync error after adding Credential Manager and OkHttp dependencies.

✔️ Awesome, I've got everything!

Great. Double check you have saved build.gradle.kts and that Gradle sync completed successfully.

ⓧ I'd like to double check the full code

Your build.gradle.kts dependencies block should include all of these lines (in addition to any other defaults from the template). Scroll through your file and confirm each one is present:

  • Compose BOM: implementation(platform("androidx.compose:compose-bom:2026.06.00")).
  • Wear Compose Material 3: implementation("androidx.wear.compose:compose-material3:1.6.2").
  • Wear Compose Foundation: implementation("androidx.wear.compose:compose-foundation:1.6.2").
  • Credential Manager: implementation("androidx.credentials:credentials:1.6.0").
  • Credential Manager Play Services: implementation("androidx.credentials:credentials-play-services-auth:1.6.0").
  • Google Identity: implementation("com.google.android.libraries.identity.googleid:googleid:1.2.0").
  • OkHttp: implementation("com.squareup.okhttp3:okhttp:5.5.0").

Also confirm that compileSdk = 37 and targetSdk = 37 are set in the android block.

Your project is ready. You have a running Wear OS app, a Google Cloud project with the Drive API enabled, and all the libraries you need for authentication and networking. Next up, you will build the watch UI with mock data so you have a visual scaffold to plug real spreadsheet data into.

Build the Watch UI with Mock Data

Your SheetGlance project builds and runs, but all it shows is the default "Hello World" text. Before wiring up authentication or network calls, you need a real screen to look at. Building the UI first with hardcoded data gives you something tangible on the watch and a visual scaffold to plug live data into later.

In this step, you will create a scrollable list of mock spreadsheet rows using Compose for Wear OS components. You will also add a placeholder sign-in button that will eventually trigger Google authentication.

In this step, get ready to:
  • Create a SheetScreen composable with a scrollable list of mock spreadsheet rows.
  • Add a placeholder Sign In button at the top of the list.
  • Wire SheetScreen into MainActivity and see it running on your watch.
Create the SheetScreen composable

Wear OS devices have round screens, so a standard vertical list would clip at the edges. TransformingLazyColumn is the Wear OS replacement for a regular list. It scales and morphs items as they approach the screen edges, keeping everything readable on a circular display.

  • In Android Studio, right-click the presentation package in the Project pane (the same folder where MainActivity.kt lives).
  • Select New, then Kotlin Class/File.
  • Choose File and name it SheetScreen.
  • Replace any generated content in SheetScreen.kt with these imports:
package com.example.sheetglance.presentation

import android.widget.Toast
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.wear.compose.foundation.lazy.TransformingLazyColumn
import androidx.wear.compose.foundation.lazy.rememberTransformingLazyColumnState
import androidx.wear.compose.material3.AppScaffold
import androidx.wear.compose.material3.Button
import androidx.wear.compose.material3.Card
import androidx.wear.compose.material3.ScreenScaffold
import androidx.wear.compose.material3.SurfaceTransformation
import androidx.wear.compose.material3.Text
import androidx.wear.compose.material3.lazy.rememberTransformationSpec
import androidx.wear.compose.material3.lazy.transformedHeight

What are these imports for?

The imports from androidx.wear.compose.material3 bring in Material 3 Wear OS components: AppScaffold, ScreenScaffold, Button, Card, and Text.

The TransformingLazyColumn and SurfaceTransformation imports handle the scrolling list with edge-scaling animations. Toast and LocalContext are for the placeholder sign-in button you will add next.

  • Below the imports in SheetScreen.kt, paste the SheetScreen composable function:
@Composable
fun SheetScreen() {
    val context = LocalContext.current
    val columnState = rememberTransformingLazyColumnState()
    val transformationSpec = rememberTransformationSpec()
    // Hardcoded mock data to preview the UI
    val mockRows = listOf("Task 1 - Done", "Task 2 - In Progress", "Task 3 - Pending")
    AppScaffold {
        ScreenScaffold(scrollState = columnState) { contentPadding ->
            TransformingLazyColumn(
                state = columnState,
                contentPadding = contentPadding,
                modifier = Modifier.fillMaxSize()
            ) {
                // Placeholder sign-in button (shows a toast for now)
                item {
                    Button(
                        onClick = { Toast.makeText(context, "Sign in coming soon", Toast.LENGTH_SHORT).show() },
                        label = { Text("Sign In") },
                        transformation = SurfaceTransformation(transformationSpec),
                        modifier = Modifier.fillMaxWidth().transformedHeight(this, transformationSpec)
                    )
                }
                // Mock spreadsheet data rows
                items(mockRows.size) { index ->
                    Card(
                        onClick = { },
                        modifier = Modifier.fillMaxWidth().transformedHeight(this, transformationSpec),
                        transformation = SurfaceTransformation(transformationSpec)
                    ) { Text(mockRows[index]) }
                }
            }
        }
    }
}

What does this code do?

  • AppScaffold is the top-level Wear OS container. It keeps the time text visible while you scroll or swipe between screens.
  • ScreenScaffold manages the scroll indicator and provides contentPadding so items do not overlap the system UI.
  • TransformingLazyColumn renders the list. Each item uses transformedHeight and SurfaceTransformation so it scales smoothly as it approaches the screen edges.
  • The Button item is a placeholder for Google sign-in. Tapping it shows a toast for now.
  • The Card items display hardcoded mock rows. In a later step, these will show real spreadsheet data.
  • Save SheetScreen.kt.

Package name mismatch?

If your project package is not com.example.sheetglance.presentation, update the package line at the top of SheetScreen.kt to match the package shown in your MainActivity.kt.

Wire SheetScreen into MainActivity

Your new composable exists but nothing calls it. The app still shows the default template content. You need to swap that out for your SheetScreen.

  • Open MainActivity.kt.
  • Find the setContent block inside onCreate(). It currently calls the default template composable (something like WearApp("Android") or similar).
  • Find this line:
setContent {
    WearApp("Android")
}
  • Replace the contents of the setContent block so it calls SheetScreen() instead:
setContent {
    SheetScreen()
}

What does this change do?

The setContent call is where your Activity tells the system what to render. By swapping in SheetScreen(), your entire watch screen now runs your new composable.

  • You can also delete the old template composable function (e.g., WearApp(), Greeting()) and any unused imports from MainActivity.kt. They are no longer needed.
  • Save MainActivity.kt.

Seeing an unresolved reference error?

  • Make sure the package line at the top of SheetScreen.kt matches the package in MainActivity.kt. If they differ, Android Studio cannot find SheetScreen().
  • If the package does match, hover over the red underline and press Alt+Enter (Windows) or Option+Enter (macOS) to let Android Studio add the import for you.

Help me fix the unresolved reference for SheetScreen in my Wear OS project.

Run the app and see your mock data

Before you run, think about what you expect to see. You created a TransformingLazyColumn with a button and three card items. What do you think the watch screen will look like?

  • Click the green Run button in the Android Studio toolbar (or press Shift+F10 on Windows / Ctrl+R on macOS) to build and deploy to your watch.

You should see a scrollable list on the round display. A "Sign In" button sits at the top, followed by three cards: "Task 1 - Done", "Task 2 - In Progress", and "Task 3 - Pending".

  • Scroll up and down on the watch. The items should scale and morph smoothly as they approach the edges of the round screen.
  • Tap the Sign In button. A toast reading "Sign in coming soon" should appear briefly.

Not seeing the list on your watch?

  • Check the Run console at the bottom of Android Studio. If it shows a build error, read the first error line. It usually points to a missing import or a typo.
  • If the app runs but still shows "Hello World", make sure you saved both SheetScreen.kt and MainActivity.kt before running.
  • If you see a blank screen, confirm that setContent calls SheetScreen() and not the old template composable.

Help me debug why my Wear OS app is not showing SheetScreen.

Why AppScaffold and ScreenScaffold?

In Wear OS Material 3, the old single Scaffold is replaced by two layers. AppScaffold sits at the Activity level and keeps the time display steady during screen transitions. ScreenScaffold wraps each individual screen and coordinates the scroll indicator with your list.

✔️ Awesome, I've got everything!

Great. Double check you have saved both files and your watch shows the scrollable list with the Sign In button and three mock data cards.

ⓧ I'd like to double check the full code

Your SheetScreen.kt is built from two blocks. Scroll up and confirm each is present in order:

  • Block 1: the package declaration and all 17 import lines.
  • Block 2: the SheetScreen() composable function with the AppScaffold, ScreenScaffold, TransformingLazyColumn, the Sign In Button item, and the mock data Card items.

Your MainActivity.kt setContent block should contain only SheetScreen() with no other composable calls.

Your watch now displays a scrollable, wrist-friendly UI with mock data and a placeholder sign-in button. Next up, you will replace that placeholder with real Google authentication using Credential Manager so you can sign in directly on the watch.

Add Google Sign-In with Credential Manager

In the last step, you built a scrollable list of mock spreadsheet rows and a placeholder sign-in button on your watch. The mock data looks great, but it is fake.

To fetch real spreadsheets, you need the user's Google account. Credential Manager lets you authenticate directly on the watch without needing a paired phone. In this step, you will wire up Google Sign-In and obtain an access token for the Google Drive API.

In this step, get ready to:
  • Build a sign-in flow using Credential Manager and extract Google account details.
  • Request an access token for the Drive API scope using AuthorizationClient.
  • Update the UI to show the signed-in state and handle sign-in errors.
Implement the sign-in flow with Credential Manager

Authentication logic does not belong inside a composable. A ViewModel holds state that survives recomposition and gives you a coroutine scope for async work like network calls.

  • In the Project panel on the left side of Android Studio, right-click the package folder that contains MainActivity.kt.
  • Select New then Kotlin Class/File.
  • Name it SheetViewModel and press Enter.
  • Replace the file contents with the imports and class shell by pasting this code:
package com.example.sheetglance

import android.app.Application
import androidx.credentials.CredentialManager
import androidx.credentials.GetCredentialRequest
import androidx.credentials.exceptions.GetCredentialCancellationException
import androidx.credentials.exceptions.NoCredentialException
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch

Why these imports?

You are pulling in the Credential Manager classes that handle the sign-in bottom sheet, plus the Google Identity library that extracts the ID token and user profile from the credential response.

AndroidViewModel gives you access to the application context, which both CredentialManager and AuthorizationClient need.

  • Below the imports, paste the UI state data class and the ViewModel class shell:
// Holds the sign-in state the UI observes
data class AuthState(
    val userName: String? = null,
    val idToken: String? = null,
    val accessToken: String? = null,
    val isSignedIn: Boolean = false,
    val errorMessage: String? = null
)

class SheetViewModel(application: Application) : AndroidViewModel(application) {

    // Replace with your Web application OAuth client ID
    private val webClientId = "your-web-client-id-here"

    private val credentialManager = CredentialManager.create(application)
    private val _authState = MutableStateFlow(AuthState())
    val authState = _authState.asStateFlow()
}

What does this code do?

  • AuthState is a simple data class that tracks the user's name, tokens, sign-in status, and any error message.
  • webClientId is the Web application OAuth client ID you created in Google Cloud Console in Step 1. Credential Manager needs the web client ID, not the Android one.
  • The MutableStateFlow lets your composable UI react whenever the auth state changes.
  • Replace "your-web-client-id-here" with your actual Web application OAuth client ID from Google Cloud Console.

Where to find your web client ID

In Google Cloud Console, go to APIs & Services then Credentials. Find the OAuth 2.0 Client ID with type Web application. Copy the Client ID string.

  • Now add the signInWithGoogle() function inside the SheetViewModel class, below the authState property:
    // Launches the Credential Manager sign-in bottom sheet
    fun signInWithGoogle(activityContext: android.app.Activity) {
        viewModelScope.launch {
            try {
                // Build the Sign In With Google option
                val googleIdOption = GetSignInWithGoogleOption.Builder(webClientId)
                    .build()

                val request = GetCredentialRequest.Builder()
                    .addCredentialOption(googleIdOption)
                    .build()

                // Show the Credential Manager bottom sheet
                val result = credentialManager.getCredential(
                    activityContext, request
                )

                // Extract the Google ID token credential
                val credential = result.credential
                val googleCredential =
                    GoogleIdTokenCredential.createFrom(credential.data)

                _authState.value = _authState.value.copy(
                    userName = googleCredential.displayName,
                    idToken = googleCredential.idToken,
                    isSignedIn = true,
                    errorMessage = null
                )
            } catch (e: NoCredentialException) {
                _authState.value = _authState.value.copy(
                    errorMessage = "No Google account found. Try again."
                )
            } catch (e: GetCredentialCancellationException) {
                _authState.value = _authState.value.copy(
                    errorMessage = "Sign-in cancelled. Tap to retry."
                )
            } catch (e: Exception) {
                _authState.value = _authState.value.copy(
                    errorMessage = "Sign-in failed: ${e.message}"
                )
            }
        }
    }

What does this code do?

  • GetSignInWithGoogleOption tells Credential Manager to show the Google account picker.
  • credentialManager.getCredential() opens the bottom sheet on the watch. The user picks their Google account.
  • GoogleIdTokenCredential.createFrom() extracts the user's display name and ID token from the response.
  • The catch blocks handle the two most common failures: no account available on the watch, and the user dismissing the sheet.
  • Save SheetViewModel.kt.

Seeing unresolved imports?

  • Make sure your build.gradle.kts includes androidx.credentials:credentials and com.google.android.libraries.identity.googleid:googleid from Step 1.
  • If Android Studio still shows red, click File then Sync Project with Gradle Files.

Help me debug unresolved imports in my SheetViewModel.

Request an access token for the Drive API

Signing in gives you an ID token that proves who the user is. To actually call the Google Drive API, you need a separate access token with the right scope.

  • Add these imports at the top of SheetViewModel.kt, below the existing import lines:
import com.google.android.gms.auth.api.identity.AuthorizationRequest
import com.google.android.gms.auth.api.identity.AuthorizationResult
import com.google.android.gms.auth.api.identity.Identity
import com.google.android.gms.common.api.Scope
import kotlinx.coroutines.tasks.await

What are these imports for?

These classes come from Google Play Services Identity. AuthorizationClient handles OAuth scope requests, and await() lets you use the Play Services Task API inside a coroutine.

  • Below the signInWithGoogle() function, add the requestDriveAccess() function inside the SheetViewModel class:
    // Requests an access token for Google Drive read-only scope
    fun requestDriveAccess(activityContext: android.app.Activity) {
        viewModelScope.launch {
            try {
                val driveScope = Scope("https://www.googleapis.com/auth/drive.readonly")
                val authRequest = AuthorizationRequest.builder()
                    .setRequestedScopes(listOf(driveScope))
                    .build()

                val authResult: AuthorizationResult =
                    Identity.getAuthorizationClient(activityContext)
                        .authorize(authRequest)
                        .await()

                if (authResult.hasResolution()) {
                    // User needs to grant permission
                    _authState.value = _authState.value.copy(
                        errorMessage = "Please grant Drive access."
                    )
                } else {
                    // Access token received
                    _authState.value = _authState.value.copy(
                        accessToken = authResult.accessToken
                    )
                }
            } catch (e: Exception) {
                _authState.value = _authState.value.copy(
                    errorMessage = "Drive access failed: ${e.message}"
                )
            }
        }
    }

What does this code do?

  • AuthorizationRequest.builder() builds a request for the https://www.googleapis.com/auth/drive.readonly scope. This gives the app read-only access to the user's Google Drive files.
  • Identity.getAuthorizationClient() is the modern replacement for the deprecated GoogleAuthUtil. It returns a Task that resolves to an AuthorizationResult.
  • If the user already granted Drive access, authResult.accessToken contains the short-lived access token. If not, hasResolution() returns true and you would need to launch a consent screen.
  • Save SheetViewModel.kt.

Why two separate flows?

Google recommends separating authentication (Credential Manager) from authorization (AuthorizationClient). Authentication proves who the user is. Authorization grants your app permission to access their data. This separation means you only ask for Drive access when the user actually needs it, not at sign-in time.

Update the UI for signed-in and signed-out states

The ViewModel holds the auth state. Now you need the composable to react to it. When the user is signed out, they see the sign-in button. When signed in, they see a greeting and a Browse Sheets button.

  • Open SheetScreen.kt and update the SheetScreen composable signature to accept the ViewModel. Find the function declaration and replace it:
// Before
fun SheetScreen()
  • Update the signature so it accepts viewModel and activity parameters, so it looks like this:
// After: accepts ViewModel and Activity for sign-in calls
fun SheetScreen(
    viewModel: SheetViewModel,
    activity: android.app.Activity
)
  • Inside the SheetScreen composable body, add a line to collect the auth state at the top, before the Scaffold:
// Collect the auth state so the UI reacts to changes
val authState by viewModel.authState.collectAsState()

What does collectAsState() do?

collectAsState() converts the StateFlow from your ViewModel into Compose state. Whenever _authState updates (after sign-in, after an error), the composable automatically recomposes to show the new values.

  • Now replace the placeholder sign-in Button in the TransformingLazyColumn with conditional UI. Find the item that renders the sign-in button and replace it with this code:
item {
    if (authState.isSignedIn) {
        // Show greeting and Browse Sheets button
        Text(
            text = "Hi, ${authState.userName}",
            style = MaterialTheme.typography.titleSmall
        )
    } else {
        // Show sign-in button
        Button(
            onClick = { viewModel.signInWithGoogle(activity) },
            modifier = Modifier.fillMaxWidth()
        ) {
            Text("Sign In")
        }
    }
}
if (authState.isSignedIn) {
    item {
        Button(
            onClick = { viewModel.requestDriveAccess(activity) },
            modifier = Modifier.fillMaxWidth()
        ) {
            Text("Browse Sheets")
        }
    }
}

What does this code do?

  • When authState.isSignedIn is false, the user sees the Sign In button. Tapping it calls signInWithGoogle().
  • When signed in, the button is replaced with a greeting showing the user's name and a Browse Sheets button that triggers requestDriveAccess().
  • The mock rows still display below these buttons. You will replace them with real data in the next step.
  • Add error handling UI. After the mock data items in the TransformingLazyColumn, add an item that shows any error message with a retry tap:
// Show error message with retry option
if (authState.errorMessage != null) {
    item {
        Text(
            text = authState.errorMessage ?: "",
            style = MaterialTheme.typography.bodySmall,
            modifier = Modifier.clickable {
                viewModel.signInWithGoogle(activity)
            }
        )
    }
}
  • Save SheetScreen.kt.
  • Now update MainActivity.kt to create the ViewModel and pass it to SheetScreen. Find the setContent block and update it:
// Before
SheetScreen()
  • Replace it with:
// After: create ViewModel and pass it to SheetScreen
val viewModel: SheetViewModel by viewModels()
SheetScreen(viewModel = viewModel, activity = this)

What changed in MainActivity?

by viewModels() is a Kotlin delegate that creates or retrieves the ViewModel scoped to this activity's lifecycle. Passing this as the activity gives the ViewModel access to the activity context it needs for Credential Manager.

  • Make sure to add the import for viewModels at the top of MainActivity.kt:
import androidx.activity.viewModels
  • Save MainActivity.kt.

Before you run the app, what do you expect to see when you tap the sign-in button? Think about what Credential Manager shows on a watch.

  • Run the app on your watch by clicking the green play button in Android Studio.

The app launches with the mock data list and the Sign In button.

  • Tap Sign In.

The Credential Manager bottom sheet slides up, showing your Google account. Select your account.

  • After signing in, the UI updates to show Hi, [your name] and a Browse Sheets button replaces the sign-in button. The mock data rows still display below.

Sign-in button does nothing?

  • Make sure your webClientId in SheetViewModel.kt is the Web application client ID, not the Android client ID. They look similar but serve different purposes.
  • Verify that your Google account is signed in on the watch itself. Go to Settings then Google then Accounts on the watch to check.
  • Check that your Google account email is added as a test user in Google Cloud Console under the OAuth consent screen.

Help me debug why the Credential Manager sign-in is not working on my Wear OS watch.

✔️ Awesome, I've got everything!

Great. Make sure you have saved all three files.

ⓧ I'd like to double check the full code

Scroll up and confirm each chunk is present in order. Your SheetViewModel.kt should contain:

  • The imports block (Credential Manager, Google Identity, AuthorizationClient, coroutines).
  • The AuthState data class.
  • The SheetViewModel class with webClientId, credentialManager, and _authState.
  • The signInWithGoogle() function.
  • The requestDriveAccess() function.

Your SheetScreen.kt should have the updated signature accepting viewModel and activity, the collectAsState() call, conditional sign-in/greeting UI, and error display.

Your MainActivity.kt should use by viewModels() and pass the ViewModel to SheetScreen.

You have authentication on your wrist. The mock data is still fake, though. In the next step, you will use that access token to hit the Google Drive API, list your real spreadsheets, and display actual data on the watch.

Fetch and Display Real Spreadsheet Data

You are signed in and the app knows who you are. But the scrollable list still shows hardcoded mock data.

In this step, you will connect to the Google Drive API to list your real Google Sheets, export one as a .xlsx file, parse its contents, and display actual spreadsheet rows on your watch. This is the moment fake data becomes live data on your wrist.

In this step, get ready to:
  • List your Google Sheets by calling the Drive API.
  • Export a selected sheet as a .xlsx file and save it to internal storage.
  • Parse the .xlsx and display real spreadsheet rows in the scrollable list.
Create the SpreadsheetItem data class

The Drive API returns each file as a JSON object with an id and a name. You need a Kotlin data class to hold those values so you can display them in a list.

  • In Android Studio, right-click your main package folder (the one containing MainActivity.kt) and select New then Kotlin Class/File.
  • Name it SpreadsheetItem and select Data Class.
  • Replace the file contents with the following code:
package com.example.sheetglance

// Data class to hold a Google Sheet's ID and display name
data class SpreadsheetItem(
    val id: String,
    val name: String
)

What does this code do?

This is a simple container with two fields. The id is the unique Google Drive file identifier you will use to export the sheet later. The name is what the user sees when picking a spreadsheet from the list.

  • Save SpreadsheetItem.kt.
  • Confirm the file appears in your project's package folder in the Project sidebar.
Add the function to list your Google Sheets

Now you need a function that calls the Drive API's files.list endpoint, filters for Google Sheets by MIME type, and returns a list of SpreadsheetItem objects.

  • Open SheetViewModel.kt and add the following imports at the top of the file if they are not already present:
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
  • Inside your ViewModel class, add a sheetsList state variable and the fetchSheetsList() function by pasting this code:
// Holds the list of user's Google Sheets
var sheetsList by mutableStateOf<List<SpreadsheetItem>>(emptyList())
    private set

// Fetch the user's Google Sheets from the Drive API
fun fetchSheetsList() {
    viewModelScope.launch(Dispatchers.IO) {
        val url = "https://www.googleapis.com/drive/v3/files" +
            "?q=mimeType='application/vnd.google-apps.spreadsheet'" +
            "&fields=files(id,name)"
        val request = Request.Builder()
            .url(url)
            .addHeader("Authorization", "Bearer $accessToken")
            .build()
        val response = OkHttpClient().newCall(request).execute()
        val body = response.body?.string() ?: return@launch
        val json = JSONObject(body)
        val files = json.getJSONArray("files")
        val items = (0 until files.length()).map { i ->
            val file = files.getJSONObject(i)
            SpreadsheetItem(file.getString("id"), file.getString("name"))
        }
        sheetsList = items
    }
}

What does this code do?

The URL queries the Drive API for files whose MIME type is application/vnd.google-apps.spreadsheet. The fields=files(id,name) parameter tells the API to return only the file ID and name, keeping the response small.

The access token from sign-in is passed in the Authorization header. The JSON response contains a files array, which gets parsed into a list of SpreadsheetItem objects.

  • Save SheetViewModel.kt.
Wire up the sheet picker in the UI

The Browse Sheets button currently does nothing meaningful. You will update it to call fetchSheetsList() and display the results as a selectable list.

  • Open SheetScreen.kt.
  • Find the Browse Sheets button's onClick handler.
  • Update it to call viewModel.fetchSheetsList().
  • Below the signed-in greeting section, add a conditional block that shows the sheet picker when sheetsList is not empty. Paste this code inside the TransformingLazyColumn content:
// Display the list of Google Sheets for selection
if (viewModel.sheetsList.isNotEmpty()) {
    items(viewModel.sheetsList.size) { index ->
        val sheet = viewModel.sheetsList[index]
        Card(
            onClick = {
                // Will fetch and display sheet data
                viewModel.fetchAndCacheSheet(sheet.id)
            },
            modifier = Modifier.fillMaxWidth()
        ) {
            Text(
                text = sheet.name,
                style = MaterialTheme.typography.bodyMedium
            )
        }
    }
}

What does this code do?

When the user taps Browse Sheets, the ViewModel fetches the sheets list from the Drive API. Once sheetsList populates, this block renders each sheet name inside a tappable Card. Tapping a card calls fetchAndCacheSheet() with that sheet's ID.

  • Save SheetScreen.kt.
  • Run the app on your watch. Tap Browse Sheets after signing in.

You should see a list of your actual Google Sheets names appear on the watch. If you tap one, the app will crash because fetchAndCacheSheet() does not exist yet. That is expected.

Don't see any sheets listed?

  • Confirm your Google account has at least one Google Sheet in Google Drive.
  • Check that the Drive API is enabled in your Google Cloud project.
  • Verify your access token includes the https://www.googleapis.com/auth/drive.readonly scope. If the scope was not granted during sign-in, sign out and sign in again.

Still stuck? Why aren't my Google Sheets showing up after tapping Browse Sheets?

Export the selected sheet as .xlsx

Listing sheet names is useful, but you need the actual cell data. The Drive API's export endpoint can convert a Google Sheet into an .xlsx file that you download and save locally.

  • Switch back to SheetViewModel.kt.
  • Add state variables for the selected sheet and parsed rows, then add the fetchAndCacheSheet() function by pasting this code inside your ViewModel class:
// Track the selected sheet
var selectedSheetId by mutableStateOf<String?>(null)
    private set
var selectedSheetName by mutableStateOf<String?>(null)
    private set

// Holds parsed spreadsheet rows
var rows by mutableStateOf<List<String>>(emptyList())
    private set

What does this code do?

These state variables track which sheet the user picked and hold the parsed row data. The UI will observe rows to display real spreadsheet content instead of mock data.

  • Now add the fetchAndCacheSheet() function below those variables:
// Export a Google Sheet as .xlsx and save to internal storage
fun fetchAndCacheSheet(fileId: String) {
    viewModelScope.launch(Dispatchers.IO) {
        selectedSheetId = fileId
        selectedSheetName = sheetsList
            .find { it.id == fileId }?.name
        val exportUrl =
            "https://www.googleapis.com/drive/v3/files/" +
            "$fileId/export?mimeType=application/" +
            "vnd.openxmlformats-officedocument" +
            ".spreadsheetml.sheet"
        val request = Request.Builder()
            .url(exportUrl)
            .addHeader("Authorization", "Bearer $accessToken")
            .build()
        val response = OkHttpClient().newCall(request).execute()
        val bytes = response.body?.bytes() ?: return@launch
        // Save to internal storage as sheet_cache.xlsx
        val file = java.io.File(context.filesDir, "sheet_cache.xlsx")
        file.writeBytes(bytes)
        // Parse the downloaded file
        rows = parseXlsx()
    }
}

What does this code do?

The export URL targets the Drive API's files.export endpoint with the .xlsx MIME type. The response body is raw binary data representing the workbook.

The bytes are written to sheet_cache.xlsx inside the app's internal storage directory. This file persists across app restarts and will power the offline caching feature in the next step.

What is the Drive API export size limit?

The Drive API export endpoint is limited to 10 MB. This is more than enough for typical spreadsheets, but if you have a very large sheet, the export will fail.

  • Save SheetViewModel.kt. The code references parseXlsx() which does not exist yet, so Android Studio will show an error. You will add it next.

Your ViewModel needs a Context

The fetchAndCacheSheet() function uses context.filesDir to access internal storage. If your ViewModel does not already hold a reference to the application context, extend AndroidViewModel(application) instead of ViewModel() and use getApplication<Application>() to get the context.

Parse the .xlsx and display real data

An .xlsx file is actually a ZIP archive containing XML files. You need a library to read the workbook structure and extract cell values from the first sheet.

  • Open your module-level build.gradle.kts file.
  • Add the Apache POI dependencies inside the dependencies block:
// Apache POI for reading .xlsx files on Android
implementation("org.apache.poi:poi:5.5.1")
implementation("org.apache.poi:poi-ooxml-lite:5.5.1")

Why poi-ooxml-lite instead of poi-ooxml?

The full poi-ooxml artifact pulls in heavy dependencies that can conflict with Android's runtime. The poi-ooxml-lite variant is substantially smaller and includes just enough OOXML schema support for reading basic .xlsx files. This keeps your Wear OS app's APK size manageable.

  • Click Sync Now in the notification bar that appears at the top of the editor.
  • Wait for the Gradle sync to complete successfully.

Gradle sync failing?

  • Verify you placed the dependencies inside the dependencies { } block, not outside it.
  • Check your internet connection. Gradle needs to download the POI artifacts from Maven Central.

Still stuck? Help me fix my Gradle sync error after adding Apache POI dependencies.

  • Switch back to SheetViewModel.kt.
  • Add the following import at the top of the file:
import org.apache.poi.xssf.usermodel.XSSFWorkbook
  • Add the parseXlsx() function inside your ViewModel class:
// Read the cached .xlsx and extract cell values
private fun parseXlsx(): List<String> {
    val file = java.io.File(context.filesDir, "sheet_cache.xlsx")
    if (!file.exists()) return emptyList()
    val workbook = XSSFWorkbook(file.inputStream())
    val sheet = workbook.getSheetAt(0)
    val result = mutableListOf<String>()
    for (row in sheet) {
        val cells = row.map { cell ->
            cell.toString()
        }
        result.add(cells.joinToString(" - "))
    }
    workbook.close()
    return result
}

What does this code do?

The function opens sheet_cache.xlsx from internal storage using XSSFWorkbook, which is Apache POI's class for reading .xlsx files.

It grabs the first sheet with getSheetAt(0), iterates through every row, converts each cell to a string, and joins the cells with " - " as a separator. Each row becomes one string in the returned list.

  • Save SheetViewModel.kt.

Now update the UI to display the parsed rows instead of mock data.

  • Open SheetScreen.kt.
  • Find the section that displays mock data rows in the TransformingLazyColumn.
  • Replace it with a conditional that shows real data when rows is populated. Paste this code:
// Display real spreadsheet rows or mock data
if (viewModel.rows.isNotEmpty()) {
    items(viewModel.rows.size) { index ->
        Card(
            onClick = { },
            modifier = Modifier.fillMaxWidth()
        ) {
            Text(
                text = viewModel.rows[index],
                style = MaterialTheme.typography.bodySmall
            )
        }
    }
} else {
    // Fallback: show mock data until a sheet is loaded
    items(3) { index ->
        Text(text = "Task ${index + 1} - Pending")
    }
}

What does this code do?

When rows has content (after the user selects and downloads a sheet), the real spreadsheet data displays. Each row string appears inside a Card for a clean wrist-friendly look. If no sheet has been loaded yet, the mock data still shows as a fallback.

  • Save SheetScreen.kt.

Before you run the app, what do you think will happen when you tap one of your Google Sheets? Will you see the cell data or an error?

  • Run the app on your watch.
  • Sign in, tap Browse Sheets, and select one of your spreadsheets.

You should see your actual spreadsheet data displayed in the scrollable list, replacing the mock data. Each row appears as a string of cell values joined by " - ".

Seeing a crash or blank screen?

  • Check Logcat in Android Studio for the error. If you see a network-related error, confirm your watch has Wi-Fi or Bluetooth internet access.
  • If you see a POI-related error, make sure poi and poi-ooxml-lite are both in your build.gradle.kts and that Gradle sync completed successfully.
  • Verify the spreadsheet you selected is not empty. An empty sheet produces an empty list with no visible rows.

Still stuck? Help me debug why my spreadsheet data is not displaying on my Wear OS watch.

✔️ Awesome, I've got everything!

Great. Double check you have saved both SheetViewModel.kt and SheetScreen.kt.

ⓧ I'd like to double check the full code

Your SheetViewModel.kt should now contain the following functions and state variables from this step. Scroll up through the chunks you added and confirm each one is present in this order:

  • sheetsList state variable and fetchSheetsList() function.
  • selectedSheetId, selectedSheetName, and rows state variables.
  • fetchAndCacheSheet(fileId) function.
  • parseXlsx() function.

Your SheetScreen.kt should contain:

  • The sheet picker block showing sheetsList items as tappable Cards.
  • The data view block showing rows with a mock data fallback.

Your SpreadsheetItem.kt data class should exist in your package folder.

Your build.gradle.kts should include org.apache.poi:poi:5.5.1 and org.apache.poi:poi-ooxml-lite:5.5.1 in the dependencies block.

Your app now fetches live data from Google Drive and renders it on your wrist. Next up, you will add offline caching so this data is available even when your watch loses internet.

Add Offline Caching

Your app now fetches real spreadsheet data from Google Drive and displays it on your wrist. The moment you sign in and pick a sheet, live rows appear in the scrollable list.

There is a problem though. If you lose Wi-Fi or walk away from your phone, that data vanishes. The app has no memory of what it fetched. Caching the downloaded sheet_cache.xlsx file and its metadata locally means you can always view your last-synced sheet, even in airplane mode.

In this step, get ready to:
  • Persist sheet metadata so the app remembers which sheet was last fetched.
  • Detect network connectivity before making API calls.
  • Display cached data when offline, with a clear offline indicator.
Persist sheet metadata with DataStore

Right now, when the app restarts, it has no idea which spreadsheet was last viewed or when it was synced. You need a lightweight store for three values: the file ID, the file name, and the last sync timestamp. DataStore is Android's modern replacement for SharedPreferences, and it handles these key-value pairs safely.

  • Add the DataStore Preferences dependency to your module-level build.gradle.kts by adding this line alongside your existing dependencies:
// Offline caching - DataStore for persisting sheet metadata
implementation("androidx.datastore:datastore-preferences:1.2.1")
  • Click **Sync Now** when the banner appears at the top of the editor.

Why DataStore instead of SharedPreferences?

SharedPreferences blocks the UI thread when reading values. DataStore uses Kotlin coroutines under the hood, so reads and writes happen asynchronously. For a watch app where every millisecond of responsiveness counts, this matters.

Now add the metadata-saving logic to your SheetViewModel. You will store three preference keys and write to them after every successful sheet download.

  • In SheetViewModel.kt, add these imports and preference key definitions at the top of the file, above the class declaration:
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import android.content.Context

// Top-level DataStore extension on Context
private val Context.dataStore: DataStore<Preferences>
    by preferencesDataStore(name = "sheet_prefs")

What does this code do?

The preferencesDataStore delegate creates a single DataStore instance tied to a file called sheet_prefs. It must be declared at the top level (outside any class) so Android only creates one instance for the whole app.

The three key types (stringPreferencesKey and longPreferencesKey) define what kind of data each preference holds. You will define the actual keys inside the ViewModel next.

  • Inside the SheetViewModel class, add the preference keys as companion object constants and a new saveSheetMetadata() function:
companion object {
    // DataStore preference keys
    private val KEY_FILE_ID = stringPreferencesKey("cachedFileId")
    private val KEY_FILE_NAME = stringPreferencesKey("cachedFileName")
    private val KEY_LAST_SYNC = longPreferencesKey("lastSyncTimestamp")
}

// Save metadata after a successful sheet download
private suspend fun saveSheetMetadata(
    context: Context,
    fileId: String,
    fileName: String
) {
    context.dataStore.edit { prefs ->
        prefs[KEY_FILE_ID] = fileId
        prefs[KEY_FILE_NAME] = fileName
        prefs[KEY_LAST_SYNC] = System.currentTimeMillis()
    }
}

What does this code do?

The three keys map to the metadata you need to remember across restarts: which spreadsheet (cachedFileId), its display name (cachedFileName), and when it was last fetched (lastSyncTimestamp as epoch milliseconds).

The saveSheetMetadata() function writes all three values atomically inside a single edit block. You will call this at the end of your existing fetchAndCacheSheet() function so metadata is always written alongside the downloaded file.

  • Find your existing fetchAndCacheSheet() function in SheetViewModel.kt. At the end of the function, after the .xlsx file is saved to internal storage, add a call to saveSheetMetadata():
// Add at the end of fetchAndCacheSheet(), after saving the .xlsx
saveSheetMetadata(context, fileId, selectedSheetName ?: "")
lastSyncTimestamp = System.currentTimeMillis().toString()
  • Also add a new state variable for the last sync timestamp near your other state variables in the ViewModel:
// Track last sync time for display
var lastSyncTimestamp: String? by mutableStateOf(null)
    private set
  • Save SheetViewModel.kt.
  • Build the project to confirm there are no compilation errors.

Seeing an unresolved reference error?

Make sure your preferencesDataStore declaration is outside the class body, at the file's top level. Declaring it inside the ViewModel will cause a compile error because the delegate requires a Context receiver.

Also confirm that the Gradle sync completed successfully after adding the DataStore dependency.

help me fix DataStore import errors in my Wear OS ViewModel

Next, add the function that loads cached data when the app starts.

  • Add the loadCachedData() function to your SheetViewModel class:
// Load cached sheet data and metadata on app startup
fun loadCachedData(context: Context) {
    viewModelScope.launch {
        val prefs = context.dataStore.data.first()
        val cachedId = prefs[KEY_FILE_ID]
        val cachedName = prefs[KEY_FILE_NAME]
        val cachedSync = prefs[KEY_LAST_SYNC]

        if (cachedId != null && cachedName != null) {
            selectedSheetId = cachedId
            selectedSheetName = cachedName
            lastSyncTimestamp = cachedSync?.toString()

            // Try to parse the cached .xlsx file
            val cacheFile = File(context.filesDir, "sheet_cache.xlsx")
            if (cacheFile.exists()) {
                parseXlsx(context)
            }
        }
    }
}

What does this code do?

On launch, loadCachedData() reads the stored metadata from DataStore. If a file ID and name exist, it restores the ViewModel's state and parses the cached sheet_cache.xlsx file. This means the learner sees their last-synced data instantly, with no network call needed.

  • Make sure you have import java.io.File at the top of SheetViewModel.kt if it is not already there.
  • In MainActivity.kt, call loadCachedData() when the activity is created. Find your setContent block and add the call before it:
// Call loadCachedData before setContent
viewModel.loadCachedData(this)
  • Save both SheetViewModel.kt and MainActivity.kt.
  • Run the app on your watch. Fetch a sheet while online. Then close the app completely (swipe it away from recents) and reopen it.

Your previously fetched sheet data loads immediately on launch, with no loading delay and no sign-in required.

Data not loading on restart?

Make sure saveSheetMetadata() is called at the end of fetchAndCacheSheet(), after the .xlsx bytes are written to disk. If the metadata is never saved, loadCachedData() has nothing to read.

Also verify that viewModel.loadCachedData(this) is called before setContent in your MainActivity.

help me debug why my cached sheet data is not loading on app restart

Add network connectivity checking

The app now remembers data across restarts, but it still tries to hit the Google Drive API every time regardless of network state. If you are offline, those calls will fail silently or crash. You need a way to check whether the watch has internet access before attempting API calls.

  • First, add the ACCESS_NETWORK_STATE permission to your AndroidManifest.xml. Add this line above the <application> tag:
<!-- Permission to check network connectivity -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
  • Save AndroidManifest.xml.

Now add the connectivity check to your ViewModel.

  • In SheetViewModel.kt, add these imports near the top of the file:
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
  • Add the isOnline() function and the isOffline state variable to your SheetViewModel class:
// Track offline state for the UI
var isOffline: Boolean by mutableStateOf(false)
    private set

// Check if the watch has internet access
fun isOnline(context: Context): Boolean {
    val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE)
        as ConnectivityManager
    val network = cm.activeNetwork ?: return false
    val caps = cm.getNetworkCapabilities(network)
        ?: return false
    return caps.hasCapability(
        NetworkCapabilities.NET_CAPABILITY_INTERNET
    )
}

What does this code do?

ConnectivityManager is the Android system service that reports the device's network state. The activeNetwork property returns the currently connected network (or null if there is none).

Calling getNetworkCapabilities() on that network lets you check specific capabilities. NET_CAPABILITY_INTERNET confirms the network is set up to access the internet. If the network or its capabilities are null, the function returns false and the app knows it is offline.

  • Now update loadCachedData() to also set the isOffline state. Add this line at the beginning of the coroutine inside loadCachedData():
// Add at the start of the launch block in loadCachedData()
isOffline = !isOnline(context)
  • Save SheetViewModel.kt.
  • Run the app on your watch. Enable airplane mode on the watch, then close and reopen the app.

The app should still load your cached data. The isOffline flag is now true, which you will wire into the UI next.

App crashing in airplane mode?

Make sure you added the ACCESS_NETWORK_STATE permission to AndroidManifest.xml. Without it, the ConnectivityManager call will throw a security exception.

help me fix a crash when checking network state on my Wear OS watch

Handle the cold start flow and update the UI

The ViewModel now knows whether it is online or offline, and it can load cached data. The last piece is making the UI respond to all the possible states: cached data with connectivity, cached data without connectivity, and no cached data at all while offline.

  • In SheetScreen.kt, find the composable that displays your spreadsheet rows in a TransformingLazyColumn. Add an offline indicator at the bottom of the list by adding a new item after the existing row items:
// Add inside your TransformingLazyColumn, after the row items
if (viewModel.isOffline && viewModel.lastSyncTimestamp != null) {
    item {
        Text(
            text = "Offline - last synced " +
                formatTimestamp(viewModel.lastSyncTimestamp!!.toLong()),
            style = MaterialTheme.typography.labelSmall,
            color = MaterialTheme.colorScheme.onSurfaceVariant,
            modifier = Modifier.padding(top = 8.dp)
        )
    }
}

What does this code do?

This conditional item only appears when the device is offline and cached data exists. It shows the user exactly when the data was last refreshed, so they know how stale it might be. The formatTimestamp() helper converts the epoch millis to a human-readable string. You will add that helper next.

  • Add the formatTimestamp() helper function in SheetScreen.kt, outside the composable:
// Format epoch millis into a readable time string
private fun formatTimestamp(epochMillis: Long): String {
    val sdf = java.text.SimpleDateFormat(
        "MMM d, h:mm a",
        java.util.Locale.getDefault()
    )
    return sdf.format(java.util.Date(epochMillis))
}
  • Save SheetScreen.kt.
  • Run the app with airplane mode still enabled. You should see your cached rows with the offline indicator text at the bottom of the list.

You should see something like "Offline - last synced Aug 23, 2:15 PM" at the bottom of your data list.

Now handle the case where the user is offline and has never synced any data.

  • In SheetScreen.kt, add a new branch to your main UI state logic. When the ViewModel has no rows, is offline, and there is no cached sheet, show a friendly message:
// Show this when offline with no cached data
if (viewModel.isOffline
    && viewModel.rows.isEmpty()
    && viewModel.selectedSheetId == null
) {
    Text(
        text = "Connect to Wi-Fi to sync your first sheet",
        style = MaterialTheme.typography.bodyMedium,
        color = MaterialTheme.colorScheme.onSurfaceVariant,
        textAlign = TextAlign.Center,
        modifier = Modifier
            .fillMaxWidth()
            .padding(16.dp)
    )
}

What does this code do?

This handles the cold start scenario where the user has never fetched a sheet and is currently offline. Instead of a blank screen or a confusing error, they see a clear instruction to connect to Wi-Fi. Once they do and sign in, the normal flow takes over.

  • Save SheetScreen.kt.

Before you run the final test, do you think the app will survive a full close-reopen cycle in airplane mode with cached data? What about without any cached data?

  • Test the full offline flow. With the app open and data loaded, enable airplane mode on your watch. Close the app completely from recents. Reopen the app.

Your cached spreadsheet data displays with no errors, and the offline indicator shows at the bottom of the list.

Seeing a blank screen instead of cached data?

Confirm that loadCachedData() is called in MainActivity before setContent. If the composable renders before the cache is loaded, it will show the signed-out state.

Also check that sheet_cache.xlsx actually exists in internal storage. You can verify by running the app with the debugger and inspecting context.filesDir.

help me debug why my Wear OS app shows a blank screen in airplane mode

✔️ Awesome, I've got everything!

Great. Double check that you have saved all your files.

ⓧ I'd like to double check the full code

Scroll up through this step and confirm each code chunk is present in your files, in this order:

  • build.gradle.kts: DataStore dependency line added.
  • SheetViewModel.kt: DataStore imports and top-level preferencesDataStore declaration.
  • SheetViewModel.kt: Companion object with KEY_FILE_ID, KEY_FILE_NAME, KEY_LAST_SYNC and saveSheetMetadata() function.
  • SheetViewModel.kt: lastSyncTimestamp state variable, isOffline state variable, and isOnline() function.
  • SheetViewModel.kt: loadCachedData() function and saveSheetMetadata() call at the end of fetchAndCacheSheet().
  • AndroidManifest.xml: ACCESS_NETWORK_STATE permission.
  • SheetScreen.kt: Offline indicator Text at the bottom of the TransformingLazyColumn, formatTimestamp() helper, and the no-cache-offline message.
  • MainActivity.kt: viewModel.loadCachedData(this) call before setContent.

Your watch app now works offline. Next up, the Secret Mission challenges you to add a manual refresh button with a loading indicator and a last-synced timestamp display.

Secret mission

Add a Refresh Button and Last-Synced Timestamp

Your app fetches spreadsheet data, but re-syncing means navigating all the way back to the sheet picker. Power users want a one-tap refresh right on the data screen, with a loading indicator and a visible timestamp showing when the data was last pulled. Your mission: wire up a refresh button, manage loading state, and display a formatted 'last synced' label on a tiny watch screen.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally and uses the Google Cloud free tier, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • SheetGlance project folder in Android Studio (local files).
  • Google Cloud project with Drive API enabled and OAuth client IDs.
  • SheetGlance app installed on your Wear OS watch or emulator.

Keep everything running

No action needed. Choose this if you are still actively building or experimenting with the SheetGlance app.

  • Your SheetGlance project remains open in Android Studio, ready for further development.
  • Your Google Cloud project stays active with the Drive API enabled and your OAuth credentials in place.
  • The SheetGlance app stays installed on your watch and continues to cache data locally.

Pause - I'll come back to this later

Free up local resources while keeping your project files and cloud configuration intact for later.

  • Close the SheetGlance project in Android Studio to free up memory.
  • In the Google Cloud Console, go to APIs & Services then Credentials. You can disable your OAuth client IDs to prevent any accidental use while the project is paused.
  • Leave the SheetGlance app on your watch. It uses minimal storage and works offline with cached data.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow each step below to fully clean up.

  • Uninstall the SheetGlance app from your watch. With your watch connected via ADB, run this command in your terminal:
adb uninstall com.example.sheetglance
  • Delete the SheetGlance project folder from your computer. In your terminal, navigate to the folder that contains your project and run:

macOS

rm -rf ~/Desktop/SheetGlance

Windows

rmdir /s /q %USERPROFILE%\Desktop\SheetGlance
  • Delete your Google Cloud project. In the Google Cloud Console, go to the Manage resources page.
  • Select your SheetGlance project from the list, then click Delete.
  • In the dialog, type your project ID, then click Shut down to confirm. This removes all APIs, credentials, and resources in that project.

Nice Work!

Nice Work!

You just built a standalone Wear OS app that signs in with Google, fetches real spreadsheet data, and displays it on your wrist. Even without Wi-Fi, your cached data is still right there.

You've learned how to:

  • Authenticate directly on a watch using Credential Manager and call the Google Drive API to export real Google Sheets data as .xlsx files.
  • Build a wrist-optimized, scrollable UI with Compose for Wear OS, Material 3, and TransformingLazyColumn.
  • Parse .xlsx files on-device and cache them locally so your spreadsheet data survives airplane mode and cold starts without a paired phone.
  • Secret Mission: Added a manual refresh button with a loading indicator and a formatted last-synced timestamp display.

Ready to quiz yourself?