Implementing the UI views

ViewMaster

The ViewMaster class handles the MIDlet UI views. It extends the GameCanvas class and performs all drawing operations in a separate thread to keep the animations smooth. The class also handles the logic for switching between views.

The class implements the singleton pattern, and the getInstance method provides access to the singleton object.

    public static ViewMaster getInstance() {
        if (instance == null) {
            instance = new ViewMaster();
            instance.initialize();
        }
        return instance;
    }

After the singleton instance is created, the MIDlet initializes all the views using the initialize method.

    private void initialize() {
        this.setCommandListener(this);
        try {
            defaultThumbnailIcon = ImageLoader.getInstance().loadImage(
                "/thumbnail.png", null);
        }
        catch (IOException e) {
        }
        try {
            Image i = ImageLoader.getInstance().loadImage("/loader_content.png",
                null);
            defaultLoaderSprite =
                new Sprite(i, i.getWidth() / 24, i.getHeight());
        }
        catch (IOException e) {
        }
        attractionsView = new AttractionsView();
        detailsView = new DetailsView();
        mapView = new MapView();
        guidesView = new GuidesView();
        aboutView = new AboutView();
        helpView = new HelpView();
        try {
            Class.forName("com.nokia.mid.payment.IAPClientPaymentListener");
            Class c = Class.forName("com.nokia.example.attractions.views."
                + "BuyGuidesView");
            buyGuidesView = (BaseView) (c.newInstance());
        }
        catch (Exception e) {
            buyGuidesView = null;
        }
    }

The showNotify method starts the drawing thread.

    protected final void showNotify() {
        hidden = false;
        Main.getInstance().refreshLocationFinder();

        // S60 doesn't call sizeChanged on start-up so it has to be called here.
        sizeChanged(getWidth(), getHeight());

        draw();
        final Graphics g = getGraphics();
        new Thread() {

            public void run() {
                while (!hidden) {
                    if (refreshScreen && canPaint) {
                        refreshScreen = false;
                        if (activeView != null) {
                            activeView.draw(g);
                            flushGraphics();
                        }
                    }
                    drawLock.sleep(50);
                }
            }
        }.start();
        new Thread() {

            public void run() {
                try {
                    sleep(500);
                    draw();
                }
                catch (InterruptedException e) {
                }
            }
        }.start();
    }

The platform calls the paint method when the GameCanvas is shown and ready to be painted on. When the paint method is called, the MIDlet sets the canPaint flag in the ViewMaster.

    public final void paint(Graphics g) {
        canPaint = true;
        forceDraw();
    }

The draw method sets a flag indicating that the screen must be refreshed.

    public final void draw() {
        refreshScreen = true;
    }

The forceDraw method sets a flag indicating that the screen must be refreshed and wakes up the drawing thread.

    public final void forceDraw() {
        draw();
        drawLock.wakeup();
    }

The setView method adds a view to the top of the view stack, activates the view, and configures the softkey buttons for the view.

    private void setView(final BaseView view) {
        Main.getInstance().callSerially(new Runnable() {

            public void run() {
                if (activeView != null) {
                    if (activeView == view) {
                        // Trying to activate a view that's already active.
                        return;
                    }
                    else {
                        activeView.deactivate();
                    }
                }

                activeView = view;
                synchronized (viewStack) {
                    // Remove the view from viewstack.
                    viewStack.removeElement(activeView);
                    // Push the view into viewstack.
                    viewStack.addElement(activeView);
                }
                activeView.activate();
                forceDraw();
            }
        });
    }

The setPreviousView method activates the previous view from the view stack.

    public final void setPreviousView() {
        BaseView view = null;
        synchronized (viewStack) {
            if (viewStack.size() >= 2) {
                // First remove and forget the current view.
                viewStack.removeElement(viewStack.lastElement());
                // Get the 2nd to last, which we want to activate.
                view = (BaseView) viewStack.lastElement();
            }
        }
        // Activate the view.
        if (view != null) {
            setView(view);
        }
    }

BaseView

All UI views extend the BaseView class.

When a view is activated, the ViewMaster calls the activate method. In this method, views refresh the data that is drawn, activate any touch event listeners, and so on.

    public void activate() {
        active = true;
        logEvent("activated");
    }

When a view is hidden, the ViewMaster calls the deactivate method. In this method, views stop their animations, deactivate touch event listeners, and so on.

    public void deactivate() {
        viewMaster.removeCommand(exitCmd);
        viewMaster.removeCommand(backCmd);
        viewMaster.removeCommand(mapCmd);
        viewMaster.removeCommand(guidesCmd);
        viewMaster.removeCommand(buyGuidesCmd);
        viewMaster.removeCommand(aboutCmd);
        viewMaster.removeCommand(helpCmd);
        active = false;
    }

The draw method draws the view to the screen. Every subclass overrides this method with their own dedicated implementation.

    public void draw(final Graphics g) {
    }

List

The List class is a custom UI component for the views. It provides a cursor for focus and a scroll bar for visualizing the number of items in the list. The MIDlet only draws those items that intersect with the displayable area. The MIDlet draws the list items using an implementation of the List.Drawer interface. The selection event is returned using the Listener interface.

The static getList method creates a new List component. If the device supports the Frame Animator API and Gesture API, the method returns a GestureList object; if not, the method returns a List object instead.

    public static List getList(Drawer drawer,
        Listener listener) {
        List list = null;
        try {
            Class.forName("com.nokia.mid.ui.frameanimator.FrameAnimator");
            Class.forName("com.nokia.mid.ui.gestures."
                + "GestureRegistrationManager");
            Class c = Class.forName("com.nokia.example.attractions.views.list."
                + "GestureList");
            list = (List) c.newInstance();
        }
        catch (Exception e) {
            list = Main.isS60Phone() ? new S60List() : new List();
        }
        list.drawer = drawer;
        list.listener = listener;
        return list;
    }

GestureList

The GestureList class extends the custom List class with support for touch events and kinetic scrolling.

When the list is enabled, the class registers the frame animator and the zone for receiving gestures.

    public final void enable() {
        if (enabled) {
            return;
        }
        enabled = true;
        super.enable();
        // Use default values for maxFps an maxPps
        // (zero param means that default is used).
        final short maxFps = 0;
        final short maxPps = 0;
        if (animator.register((short) 0, (short) 0, maxFps, maxPps, this)
            != true) {
            throw new RuntimeException("FrameAnimator.register() failed!");
        }
        if (GestureRegistrationManager.register(view, zone) != true) {
            throw new RuntimeException(
                "GestureRegistrationManager.register() failed!");
        }
        GestureRegistrationManager.setListener(view, this);
    }

When the list is disabled, the class unregisters the gesture zone and the frame animator.

    public final void disable() {
        if (!enabled) {
            return;
        }
        enabled = false;
        GestureRegistrationManager.unregister(view, zone);
        super.disable();
        animator.unregister();
    }

DetailsView

The DetailsView shows the details of an attraction. The view can contain a lot of text, and drawing text can be a slow operation. To achieve smooth scrolling animations, the MIDlet draws the text to a buffer. The MIDlet updates the buffer only if the text changes or the screen dimensions change.

The drawBuffer method draws the details of an attraction to the buffer.

    protected final void drawBuffer(Graphics g) {
        int w = getContentWidth();
        int x0 = 0;
        int y0 = 0;

        g.setFont(Visual.SMALL_BOLD_FONT);
        g.setColor(Visual.LIST_SECONDARY_COLOR);
        g.drawString(attraction.getStreet(), x0, y0, Graphics.TOP
            | Graphics.LEFT);

        if (attraction.getDistance() != null) {
            g.drawString(attraction.getDistance(), w, y0, Graphics.TOP
                | Graphics.RIGHT);
        }

        y0 += Visual.SMALL_BOLD_FONT.getHeight() + 4;

        g.setFont(Visual.SMALL_FONT);
        g.setColor(Visual.LIST_PRIMARY_COLOR);

        for (int i = 0; i < lines.size(); i++) {
            g.drawString((String) lines.elementAt(i), x0, y0, Graphics.TOP
                | Graphics.LEFT);
            y0 += Visual.SMALL_FONT.getHeight();
        }
    }

BuyGuidesView

The BuyGuidesView shows a list of purchasable guides. The MIDlet loads a list of guide IDs from the back-end server, and Nokia Store provides the titles, descriptions, and prices for the guides according to the IDs.

The IAPClientPaymentManager is initialized in the class constructor.

    BuyGuidesView()
        throws IAPClientPaymentException {
        // ...
        manager = IAPClientPaymentManager.getIAPClientPaymentManager();
        IAPClientPaymentManager.setIAPClientPaymentListener(this);
    }

The loadAccountAndGuides method calls the IAPClientPaymentManager.getUserAndDeviceId method to retrieve the user's account information.

    private void loadAccountAndGuides() {
        if (loadingAccount) {
            return;
        }
        loadingAccount = true;

        // For restoring guides user's account is needed
        int status = manager.getUserAndDeviceId(
            IAPClientPaymentManager.DEFAULT_AUTHENTICATION);
        if (status != IAPClientPaymentManager.SUCCESS) {
            showAlert("Authorization failure " + status,
                "Authorization failure", "Authorization process failed. "
                + getPaymentError(status));
            viewMaster.setPreviousView();
            loadingAccount = false;
        }
    }

The userAndDeviceDataReceived callback method returns the account information.

    public final void userAndDeviceDataReceived(int status,
        IAPClientUserAndDeviceData ud) {
        if (status == OK) {
            account = ud.getAccount();
            loadGuides();
        }
        else {
            showAlert("Authorization listener failure " + status,
                "Authorization failure",
                "Authorization process failed. " + getPaymentError(status));
            viewMaster.setPreviousView();
        }
        loadingAccount = false;
    }

The loadGuides method starts a network operation to load the guide IDs from the back-end server. The back-end server needs the user's account to mark already purchased guides restorable. After the IDs are loaded, the method calls the loadProductData method.

    private void loadGuides() {
        if (loadingGuides) {
            return;
        }
        loadingGuides = true;
        new NewGuidesOperation(new NewGuidesOperation.Listener() {

            public void guidesReceived(Vector newGuides) {
                if (newGuides == null) {
                    showAlert("Network failure", "Network failure",
                        "Connecting network failed.");
                    viewMaster.setPreviousView();
                    loadingGuides = false;
                }
                else {
                    waitingProductData = new Hashtable();
                    for (int i = 0, length = newGuides.size(); i < length; i++) {
                        Guide guide = (Guide) newGuides.elementAt(i);
                        if (!data.getGuides().contains(guide)) {
                            waitingProductData.put(guide.getId(), guide);
                            guide.setUrl(GUIDE_URL_PREFIX + guide.getId());
                            guide.setAccount(account);
                        }
                    }
                    loadProductData();
                }
            }
        }, NEW_GUIDES_URL, account).start();
    }

The loadProductData method loads the titles, descriptions, and prices for the guides from Nokia Store.

    private void loadProductData() {
        if (waitingProductData.isEmpty()) {
            waitingProductData = null;
            if (guides == null) {
                guides = new Vector();
                loadingGuides = false;
                if (isActive()) {
                    viewMaster.draw();
                }
            }
            return;
        }

        String[] productIds = new String[waitingProductData.size()];
        Enumeration e = waitingProductData.keys();
        for (int i = 0; e.hasMoreElements(); i++) {
            productIds[i] = ((String) e.nextElement());
        }
        int status = manager.getProductData(productIds);
        if (status != IAPClientPaymentManager.SUCCESS) {
            waitingProductData = null;
            showAlert("Metadata failure " + status, "Connection failure",
                "Loading new guides failed. " + getPaymentError(status));
            viewMaster.setPreviousView();
            loadingGuides = false;
        }
    }

The productDataListReceived method is called after the product data is loaded.

    public final void productDataListReceived(int status,
        IAPClientProductData[] productDataList) {
        if (status == OK) {
            guides = new Vector();
            for (int i = 0, size = productDataList.length; i < size; i++) {
                IAPClientProductData productData = productDataList[i];
                if (productData.getProductId() != null) {
                    Guide guide = (Guide) waitingProductData.remove(productData.
                        getProductId());
                    String title = productData.getTitle();
                    if (title == null) {
                        title = productData.getShortDescription();
                    }
                    if (title == null) {
                        title = "unknown";
                    }
                    String price = productData.getPrice();
                    if (price == null) {
                        price = "unknown";
                    }
                    if (guide != null) {
                        guide.setCity(title);
                        guide.setPrice(price);
                        guides.addElement(guide);
                    }
                }
            }
            if (isActive()) {
                showGuides();
                viewMaster.draw();
            }
        }
        else {
            showAlert("Metadata listener failure " + status,
                "Connection failure",
                "Loading new guides failed. " + getPaymentError(status));
            viewMaster.setPreviousView();
        }
        waitingProductData = null;
        loadingGuides = false;
    }

The MIDlet calls the selectGuide method when the user selects a guide from the list. The method starts the purchase process.

    private void selectGuide(int index) {
        if (purchasing != null) {
            return;
        }
        purchasing = (Guide) guides.elementAt(index);
        if (purchasing.isRestorable()) {
            // Restore guide from the backend
            guidePurchased("");
        }
        else {
            // Purchase guide using Nokia Store
            int status = manager.purchaseProduct(purchasing.getId(),
                IAPClientPaymentManager.FORCED_AUTOMATIC_RESTORATION);
            if (status != IAPClientPaymentManager.SUCCESS) {
                purchasing = null;
                showAlert("Purchase failure " + status, "Purchase failure", "Purchase process failed. "
                    + getPaymentError(status));
            }
        }
    }

After the purchase is completed, the purchaseCompleted method is called.

    public final void purchaseCompleted(int status, String purchaseTicket) {
        switch (status) {
            case OK:
                guidePurchased(purchaseTicket);
                break;
            case RESTORABLE:
                guidePurchased(purchaseTicket == null ? "" : purchaseTicket);
                break;
            default:
                purchasing = null;
                showAlert("Purchase listener failure " + status,
                    "Purchase failure",
                    "Purchase process failed. " + getPaymentError(status));
                break;
        }
    }

The guidePurchased method adds a purchase ticket to the guide, moves the guide from the new guides list to the list of guides in the DataModel, and opens a view that shows the attractions in the guide.

    private void guidePurchased(String purchaseTicket) {
        if (purchasing == null) {
            return;
        }
        purchasing.setPurchaseTicket(purchaseTicket);
        guides.removeElement(purchasing);
        data.getGuides().addElement(purchasing);
        data.setCurrentGuideIndex(data.getGuides().indexOf(purchasing));
        data.saveGuides();
        purchasing = null;
        viewMaster.showAttractionsView();
    }